Series Context: This is Part 7 of the 17-part USB Development Mastery series. Part 6 covered CDC Virtual COM Port. Now we tackle HID — the device class that needs no custom driver on any major operating system, making it the fastest path from firmware to usable device on a host PC.
1
USB Fundamentals
USB system architecture, transfer types, host/device model, protocol stack
2
Electrical & Hardware Layer
D+/D- signalling, pull-ups, connectors, USB-C, STM32 USB peripherals
3
Protocol & Enumeration
Enumeration sequence, USB packets, descriptors, endpoint concepts
4
USB Device Classes
HID, CDC, MSC, MIDI, Audio, composite devices, vendor class
5
TinyUSB Deep Dive
Stack architecture, execution model, STM32 integration, descriptor callbacks
6
CDC Virtual COM Port
CDC class, bulk transfers, printf over USB, baud rate handling
7
HID Devices
HID descriptors, report format, keyboard/mouse/gamepad implementation
You Are Here
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
HID Class Architecture
The Human Interface Device (HID) class was defined by USB-IF to handle input and output for devices that humans interact with directly. Despite the name, HID is used for a remarkably wide range of purposes: traditional keyboards and mice, game controllers, uninterruptible power supply panels, barcode scanners, biometric sensors, and custom vendor devices that need a zero-driver-install path to the host.
Endpoint Configuration
HID uses interrupt endpoints, not bulk or isochronous:
- Interrupt IN endpoint (mandatory): Device sends reports to the host. The host polls this endpoint at the interval specified by
bInterval in the endpoint descriptor. For Full-Speed USB, bInterval is in milliseconds (minimum 1ms, maximum 255ms). For High-Speed, it is in units of 125µs (1=125µs, 4=1ms, 8=1ms, etc.).
- Interrupt OUT endpoint (optional): Host sends reports to the device. Used for keyboard LED control, feature configuration, or device feedback. If omitted, host-to-device reports go through EP0 with SET_REPORT control requests.
HID Descriptor Chain
Unlike CDC, HID uses a single interface. The descriptor chain within that interface is: Interface Descriptor → HID Descriptor → Endpoint Descriptor(s). The HID descriptor (bDescriptorType=0x21) sits between the interface and endpoint descriptors and references the report descriptor:
| Field | Value | Meaning |
| bLength | 9 | Size of HID descriptor itself |
| bDescriptorType | 0x21 | HID descriptor type code |
| bcdHID | 0x0111 | HID spec version 1.11 (use 0x0111) |
| bCountryCode | 0x00 | Country code for localized keyboards (0=not localized) |
| bNumDescriptors | 0x01 | Number of subordinate descriptors (always 1 for report descriptor) |
| bDescriptorType[0] | 0x22 | Type of subordinate descriptor = Report Descriptor |
| wDescriptorLength[0] | N | Byte length of the report descriptor (critical — must be exact) |
The host fetches the report descriptor via a GET_DESCRIPTOR request for type 0x22. The report descriptor is where all the magic lives — it is a byte stream that defines the exact format and meaning of every byte in every report your device sends or receives.
Why No Driver Is Needed
Every major OS ships with a generic HID driver that can parse any compliant report descriptor and expose the device through a standardized interface. Windows uses hid.dll and hidclass.sys. Linux uses the usbhid kernel module. macOS uses IOHIDFamily. The report descriptor tells the driver what the data means — the driver does not need to know the vendor ID or device type ahead of time.
Report Descriptor Language
The HID report descriptor is a compact bytecode language. Each item is 1 to 5 bytes: a 1-byte prefix (containing item type, tag, and size) followed by 0, 1, 2, or 4 data bytes. The prefix byte is: bits[7:4]=tag, bits[3:2]=type, bits[1:0]=size.
Item Types
| Type | Code | Items |
| Main | 0 | Input, Output, Feature, Collection, End Collection |
| Global | 1 | Usage Page, Logical Min/Max, Physical Min/Max, Unit, Report Size, Report ID, Report Count, Push, Pop |
| Local | 2 | Usage, Usage Min/Max, Designator, String Index |
Collection Hierarchy
Every HID device wraps its reports in a Collection hierarchy. The top-level collection is always Application (0x01). Inside you may have Physical (0x00) collections to group related fields, and Logical (0x02) collections for abstract groupings.
Annotated 6-Key Keyboard + Mouse Descriptor
/* Combined keyboard (6-key rollover) + mouse report descriptor.
Two Report IDs: 1=keyboard, 2=mouse.
Total: ~68 bytes. Comments show byte values in hex. */
uint8_t const desc_hid_report[] = {
/* ── KEYBOARD (Report ID 1) ─────────────────────────────── */
0x05, 0x01, /* Usage Page (Generic Desktop) tag=0,type=1 */
0x09, 0x06, /* Usage (Keyboard) tag=0,type=2 */
0xA1, 0x01, /* Collection (Application) tag=A,type=0 */
0x85, 0x01, /* Report ID (1) tag=8,type=1 */
/* Modifier byte: 8 bits, one per modifier key */
0x05, 0x07, /* Usage Page (Key Codes) */
0x19, 0xE0, /* Usage Minimum (0xE0 = Left Control) */
0x29, 0xE7, /* Usage Maximum (0xE7 = Right GUI) */
0x15, 0x00, /* Logical Minimum (0) */
0x25, 0x01, /* Logical Maximum (1) */
0x75, 0x01, /* Report Size (1 bit) */
0x95, 0x08, /* Report Count (8 — 8 modifier bits) */
0x81, 0x02, /* Input (Data, Variable, Absolute) */
/* Reserved byte (required by boot protocol) */
0x95, 0x01, /* Report Count (1) */
0x75, 0x08, /* Report Size (8 bits) */
0x81, 0x03, /* Input (Constant) — reserved byte */
/* 6 key code slots: each 0x00-0xDD */
0x95, 0x06, /* Report Count (6 key slots) */
0x75, 0x08, /* Report Size (8 bits each) */
0x15, 0x00, /* Logical Minimum (0) */
0x25, 0xDD, /* Logical Maximum (0xDD = max keycode) */
0x05, 0x07, /* Usage Page (Key Codes) */
0x19, 0x00, /* Usage Minimum (0) */
0x29, 0xDD, /* Usage Maximum (0xDD) */
0x81, 0x00, /* Input (Data, Array, Absolute) */
0xC0, /* End Collection */
/* ── MOUSE (Report ID 2) ────────────────────────────────── */
0x05, 0x01, /* Usage Page (Generic Desktop) */
0x09, 0x02, /* Usage (Mouse) */
0xA1, 0x01, /* Collection (Application) */
0x85, 0x02, /* Report ID (2) */
0x09, 0x01, /* Usage (Pointer) */
0xA1, 0x00, /* Collection (Physical) */
/* 3 buttons (left, right, middle) */
0x05, 0x09, /* Usage Page (Buttons) */
0x19, 0x01, /* Usage Minimum (Button 1) */
0x29, 0x03, /* Usage Maximum (Button 3) */
0x15, 0x00, /* Logical Minimum (0) */
0x25, 0x01, /* Logical Maximum (1) */
0x95, 0x03, /* Report Count (3 buttons) */
0x75, 0x01, /* Report Size (1 bit each) */
0x81, 0x02, /* Input (Data, Variable, Absolute) */
/* 5 padding bits to align to byte boundary */
0x95, 0x01, /* Report Count (1) */
0x75, 0x05, /* Report Size (5 bits padding) */
0x81, 0x03, /* Input (Constant) */
/* X and Y relative movement */
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 bits) */
0x95, 0x03, /* Report Count (3: X, Y, wheel) */
0x81, 0x06, /* Input (Data, Variable, Relative) */
0xC0, /* End Collection (Physical) */
0xC0 /* End Collection (Application) */
};
Keyboard Report
The 8-byte boot keyboard report is the most important HID format to understand thoroughly — it is the format that BIOS, pre-boot environments, and many simple HID drivers expect. Even if your device uses report protocol, understanding the boot format clarifies the field layout.
Boot Keyboard Report Format
| Byte | Field | Description |
| 0 | Modifier | Bit mask: [7]R-GUI [6]R-Alt [5]R-Shift [4]R-Ctrl [3]L-GUI [2]L-Alt [1]L-Shift [0]L-Ctrl |
| 1 | Reserved | Always 0x00 (required by boot protocol for padding) |
| 2 | Keycode[0] | First held key's HID keycode (0x00 = no key) |
| 3 | Keycode[1] | Second held key |
| 4 | Keycode[2] | Third held key |
| 5 | Keycode[3] | Fourth held key |
| 6 | Keycode[4] | Fifth held key |
| 7 | Keycode[5] | Sixth held key (6-key rollover limit) |
Selected HID Keycodes
| Keycode (hex) | Key | Keycode (hex) | Key |
| 0x04 | a | 0x27 | 0 (zero) |
| 0x05 | b | 0x28 | Enter / Return |
| 0x1E | 1 | 0x29 | Escape |
| 0x1F | 2 | 0x2A | Backspace |
| 0x20 | 3 | 0x2B | Tab |
| 0x21 | 4 | 0x2C | Space |
| 0x22 | 5 | 0x3A | F1 |
| 0x23 | 6 | 0x3B | F2 |
| 0x24 | 7 | 0x4F | Right Arrow |
| 0x25 | 8 | 0x50 | Left Arrow |
| 0x26 | 9 | 0x51 | Down Arrow |
Typing "Hello" One Character Per Frame
/* hid_keyboard.c — type a string one character per USB polling interval */
#include "tusb.h"
/* ASCII to HID keycode conversion for uppercase A-Z and basic symbols.
Returns {modifier, keycode} pair. modifier=0x02 means Left Shift. */
typedef struct { uint8_t mod; uint8_t key; } hid_key_t;
static hid_key_t ascii_to_hid(char c) {
/* Lowercase a-z */
if (c >= 'a' && c <= 'z') return (hid_key_t){0x00, (uint8_t)(0x04 + c - 'a')};
/* Uppercase A-Z (shift + lowercase keycode) */
if (c >= 'A' && c <= 'Z') return (hid_key_t){0x02, (uint8_t)(0x04 + c - 'A')};
/* Digits */
if (c >= '1' && c <= '9') return (hid_key_t){0x00, (uint8_t)(0x1E + c - '1')};
if (c == '0') return (hid_key_t){0x00, 0x27};
/* Punctuation */
if (c == ' ') return (hid_key_t){0x00, 0x2C};
if (c == '\n') return (hid_key_t){0x00, 0x28}; /* Enter */
if (c == '!') return (hid_key_t){0x02, 0x1E}; /* Shift+1 */
return (hid_key_t){0x00, 0x00}; /* unsupported character */
}
static const char *s_type_string = "Hello World!\n";
static int s_type_index = 0;
static bool s_key_released = true; /* alternate between key-down and key-up */
/* Call this from the main loop whenever tud_hid_ready() returns true */
void keyboard_task(void) {
if (!tud_hid_ready()) return;
if (s_type_string[s_type_index] == '\0') return; /* done */
if (s_key_released) {
/* Send key-down report */
hid_key_t k = ascii_to_hid(s_type_string[s_type_index]);
uint8_t keycodes[6] = {k.key, 0, 0, 0, 0, 0};
tud_hid_keyboard_report(HID_ITF_PROTOCOL_KEYBOARD, k.mod, keycodes);
s_key_released = false;
} else {
/* Send key-up (all zeros) before next key */
uint8_t keycodes[6] = {0, 0, 0, 0, 0, 0};
tud_hid_keyboard_report(HID_ITF_PROTOCOL_KEYBOARD, 0, keycodes);
s_key_released = true;
s_type_index++;
}
}
/* Required callback — host calls GET_REPORT via EP0 */
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; (void)reqlen;
buffer[0] = 0; /* no key currently pressed */
return 1;
}
/* Required callback — host sends SET_REPORT (e.g. LED state) */
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)buffer; (void)bufsize;
}
Mouse Report
The standard HID mouse uses relative movement — X and Y deltas tell the host how many units the mouse moved since the last report, not where on the screen the cursor is. Signed 8-bit integers are typically used, giving a range of -127 to +127 units per report.
Mouse Report Structure
| Byte | Field | Description |
| 0 | Buttons | Bits [0]=Left, [1]=Right, [2]=Middle, [3-7]=padding (set to 0) |
| 1 | X | Signed 8-bit relative X movement (negative = left) |
| 2 | Y | Signed 8-bit relative Y movement (negative = up) |
| 3 | Wheel | Signed 8-bit scroll wheel delta (negative = scroll down) |
USB Mouse Drawing a Circle
/* mouse_circle.c — USB mouse traces a circle using sine/cosine */
#include "tusb.h"
#include <math.h> /* sinf(), cosf() — link with -lm */
#define CIRCLE_RADIUS 50 /* pixels (host DPI dependent) */
#define CIRCLE_STEPS 128 /* steps per full revolution */
static int s_step = 0;
static float s_prev_x = (float)CIRCLE_RADIUS;
static float s_prev_y = 0.0f;
void mouse_task(void) {
if (!tud_hid_ready()) return;
float angle = (2.0f * 3.14159265f * (float)s_step) / (float)CIRCLE_STEPS;
float curr_x = (float)CIRCLE_RADIUS * cosf(angle);
float curr_y = (float)CIRCLE_RADIUS * sinf(angle);
/* Delta from previous position (relative mouse movement) */
int8_t dx = (int8_t)(curr_x - s_prev_x);
int8_t dy = (int8_t)(curr_y - s_prev_y);
s_prev_x = curr_x;
s_prev_y = curr_y;
/* hid_mouse_report_t: buttons, x, y, wheel, pan */
tud_hid_mouse_report(HID_ITF_PROTOCOL_MOUSE,
0x00, /* no buttons */
dx, dy,
0, /* no wheel */
0); /* no pan */
s_step = (s_step + 1) % CIRCLE_STEPS;
}
Gamepad / Custom HID
TinyUSB ships with a pre-built HID_REPORT_DESC_GAMEPAD macro that defines a standard gamepad: two analog sticks (X/Y/Z/Rz axes), 32 buttons, and a D-pad hat switch. This is sufficient for most game controller applications and is compatible with the browser's GamepadAPI and WebHID API.
Gamepad Descriptor and Report
/* gamepad_hid.c — USB gamepad with 2 sticks, 16 buttons, hat switch */
#include "tusb.h"
/* tusb_config.h — enable HID */
/* #define CFG_TUD_HID 1 */
uint8_t const desc_hid_report[] = {
HID_REPORT_DESC_GAMEPAD() /* TinyUSB built-in gamepad descriptor macro */
};
/* hid_gamepad_report_t (from TinyUSB hid.h):
typedef struct TU_ATTR_PACKED {
int8_t x; // Left stick X: -127 to 127
int8_t y; // Left stick Y
int8_t z; // Right stick X (Z axis)
int8_t rz; // Right stick Y (Rz axis)
uint8_t rx; // Left trigger: 0-255
uint8_t ry; // Right trigger: 0-255
uint8_t hat; // D-pad: 0=N,1=NE,2=E,3=SE,4=S,5=SW,6=W,7=NW,8=center
uint32_t buttons; // 32 button bits
} hid_gamepad_report_t; */
static hid_gamepad_report_t s_gamepad = {0};
void gamepad_update(int8_t lx, int8_t ly, int8_t rx, int8_t ry,
uint32_t buttons, uint8_t hat) {
s_gamepad.x = lx;
s_gamepad.y = ly;
s_gamepad.z = rx;
s_gamepad.rz = ry;
s_gamepad.buttons = buttons;
s_gamepad.hat = hat;
}
void gamepad_task(void) {
if (tud_hid_ready()) {
tud_hid_report(0, &s_gamepad, sizeof(s_gamepad));
}
}
Three-Report HID (Keyboard + Mouse + Gamepad)
/* Multi-report HID: Report ID 1=keyboard, 2=mouse, 3=gamepad.
One HID interface, three report IDs — avoids composite device complexity. */
uint8_t const desc_hid_report[] = {
/* Report ID 1: keyboard (6KRO boot format) */
HID_REPORT_DESC_KEYBOARD( HID_REPORT_ID(1) ),
/* Report ID 2: mouse */
HID_REPORT_DESC_MOUSE( HID_REPORT_ID(2) ),
/* Report ID 3: gamepad */
HID_REPORT_DESC_GAMEPAD( HID_REPORT_ID(3) )
};
/* Dispatch to correct report type based on which device is active */
void hid_multi_task(void) {
if (!tud_hid_ready()) return;
/* Send the report for whichever device has new data */
/* (In a real implementation, use flags to track which report changed) */
/* Keyboard example: send 'A' key press */
uint8_t keycodes[6] = {HID_KEY_A, 0, 0, 0, 0, 0};
tud_hid_keyboard_report(1, 0, keycodes); /* Report ID 1 */
}
Custom Sensor HID (Vendor Usage Page)
For devices that do not fit any standard HID usage (sensor, measurement instrument, custom control panel), use the vendor-defined usage page (0xFF00–0xFFFF). The host's generic HID driver will still load — your application reads raw reports using the hid library or hidapi.
IMU Sensor HID Descriptor
/* Custom IMU sensor: 6-axis (3×int16 accelerometer + 3×int16 gyroscope).
IN report: 12 bytes of sensor data (1 report ID + 12 data = 13 bytes total).
Feature report: sensor configuration (sample rate, range). */
uint8_t const desc_hid_report_imu[] = {
/* ── IN Report: sensor data (Report ID 1) ─── */
0x06, 0x00, 0xFF, /* Usage Page (Vendor Defined 0xFF00) */
0x09, 0x01, /* Usage (Vendor Usage 0x01) */
0xA1, 0x01, /* Collection (Application) */
0x85, 0x01, /* Report ID (1) — IN report */
/* 6 signed 16-bit values: Ax, Ay, Az, Gx, Gy, Gz */
0x09, 0x02, /* Usage (Vendor: Accel X) */
0x09, 0x03, /* Usage (Vendor: Accel Y) */
0x09, 0x04, /* Usage (Vendor: Accel Z) */
0x09, 0x05, /* Usage (Vendor: Gyro X) */
0x09, 0x06, /* Usage (Vendor: Gyro Y) */
0x09, 0x07, /* Usage (Vendor: Gyro Z) */
0x15, 0x80, /* Logical Minimum (-32768 low byte) */
0x26, 0xFF, 0x7F, /* Logical Maximum (32767) */
0x75, 0x10, /* Report Size (16 bits) */
0x95, 0x06, /* Report Count (6 values) */
0x81, 0x02, /* Input (Data, Variable, Absolute) */
/* ── Feature Report: config (Report ID 2) ─── */
0x85, 0x02, /* Report ID (2) — Feature report */
0x09, 0x10, /* Usage (Vendor: Sample Rate) */
0x09, 0x11, /* Usage (Vendor: Accel Range) */
0x09, 0x12, /* Usage (Vendor: Gyro Range) */
0x15, 0x00, /* Logical Minimum (0) */
0x26, 0xFF, 0x00, /* Logical Maximum (255) */
0x75, 0x08, /* Report Size (8 bits) */
0x95, 0x03, /* Report Count (3 config bytes) */
0xB1, 0x02, /* Feature (Data, Variable, Absolute)*/
0xC0 /* End Collection */
};
/* IMU report structure */
typedef struct __attribute__((packed)) {
int16_t ax, ay, az; /* Accelerometer: mg (milli-g) */
int16_t gx, gy, gz; /* Gyroscope: mdps (milli-degrees/sec) */
} imu_data_t;
/* Send IMU data at 100Hz (call from 10ms periodic task) */
void imu_hid_task(void) {
if (!tud_hid_ready()) return;
/* Read from your IMU sensor (MPU6050, ICM42688, etc.) */
imu_data_t imu;
imu_sensor_read(&imu); /* your BSP function */
/* Send as HID IN report with Report ID 1 */
tud_hid_report(1, &imu, sizeof(imu));
}
Python Host: Reading the IMU via hidapi
pip install hid
/* Python code — imu_reader.py */
/*
import hid
import struct
import time
VID = 0xCafe
PID = 0x4002
dev = hid.device()
dev.open(VID, PID)
dev.set_nonblocking(0) # blocking read
print(f"Manufacturer: {dev.get_manufacturer_string()}")
print(f"Product: {dev.get_product_string()}")
# Configure sensor: sample_rate=100Hz, accel_range=4g, gyro_range=500dps
# Feature report: [report_id, sample_rate, accel_range, gyro_range]
dev.send_feature_report([0x02, 100, 1, 1])
print("Reading IMU data (Ctrl+C to stop)...")
try:
while True:
# Read 13 bytes: 1 report_id + 12 data bytes (6 × int16)
data = dev.read(13)
if data and data[0] == 0x01:
ax, ay, az, gx, gy, gz = struct.unpack_from('<6h', bytes(data), 1)
print(f"Accel: X={ax:6d} Y={ay:6d} Z={az:6d} mg "
f"Gyro: X={gx:6d} Y={gy:6d} Z={gz:6d} mdps")
time.sleep(0.01)
except KeyboardInterrupt:
pass
finally:
dev.close()
*/
Boot Protocol vs Report Protocol
BIOS and UEFI firmware need to handle keyboard and mouse input before the OS loads. They cannot parse arbitrary report descriptors, so the HID spec defines a boot protocol with a fixed, hardcoded report format that all compliant boot-capable keyboards and mice must support.
| Property | Boot Protocol | Report Protocol |
| Report format | Fixed 8-byte keyboard / 3-byte mouse | Defined by report descriptor |
| Parser required | None — format is hardcoded | Full descriptor parser |
| Who uses it | BIOS, UEFI, pre-boot screens | OS after boot |
| Activated by | SET_PROTOCOL(0) request | SET_PROTOCOL(1) or default |
| bInterfaceSubClass | Must be 0x01 | 0x00 (no boot support) |
| bInterfaceProtocol | 0x01=keyboard, 0x02=mouse | 0x00=none |
Handling Protocol Switching
/* boot_keyboard.c — keyboard supporting both boot and report protocol */
#include "tusb.h"
static uint8_t s_protocol = HID_PROTOCOL_REPORT; /* start in report protocol */
/* TinyUSB calls this when host sends SET_PROTOCOL */
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) {
/* In boot protocol, report_id is 0 (no report IDs used).
In report protocol, report_id matches your descriptor. */
(void)itf; (void)report_id; (void)report_type; (void)reqlen;
if (s_protocol == HID_PROTOCOL_BOOT) {
/* Return 8-byte boot format */
memset(buffer, 0, 8);
return 8;
} else {
/* Return current report-protocol data */
memset(buffer, 0, reqlen);
return reqlen;
}
}
/* TinyUSB calls this when protocol changes (BIOS switch to boot, OS switch to report) */
void tud_hid_set_protocol_cb(uint8_t itf, uint8_t protocol) {
(void)itf;
s_protocol = protocol;
/* protocol: 0 = HID_PROTOCOL_BOOT, 1 = HID_PROTOCOL_REPORT */
}
/* Send keyboard report in the correct format for current protocol */
void keyboard_send(uint8_t modifier, uint8_t keycode) {
if (!tud_hid_ready()) return;
if (s_protocol == HID_PROTOCOL_BOOT) {
/* Boot format: 8 bytes, no report ID */
uint8_t buf[8] = {modifier, 0, keycode, 0, 0, 0, 0, 0};
tud_hid_report(0, buf, 8);
} else {
/* Report format with Report ID */
uint8_t keycodes[6] = {keycode, 0, 0, 0, 0, 0};
tud_hid_keyboard_report(1, modifier, keycodes);
}
}
/* Interface must declare boot support in its descriptor:
bInterfaceSubClass = 0x01 (supports boot interface)
bInterfaceProtocol = 0x01 (keyboard)
These values go in the USB interface descriptor. */
OUT & Feature Reports
Host-to-device communication in HID uses either OUT reports (through the interrupt OUT endpoint or EP0 SET_REPORT) or Feature reports (always through EP0 GET/SET_REPORT). The most common example is keyboard LED control.
Keyboard LED Output Report
/* Keyboard LED output report format (standard HID):
Byte 0: LED bits
bit 0: Num Lock
bit 1: Caps Lock
bit 2: Scroll Lock
bit 3: Compose
bit 4: Kana
bits 5-7: padding (constant 0)
*/
/* GPIO-connected LEDs */
#define LED_PIN_NUMLOCK GPIO_PIN_0
#define LED_PIN_CAPSLOCK GPIO_PIN_1
#define LED_PIN_SCROLLLOCK GPIO_PIN_2
#define LED_GPIO_PORT GPIOB
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;
if (report_type == HID_REPORT_TYPE_OUTPUT && bufsize >= 1) {
/* buffer[0] contains the LED bitmask */
uint8_t led_state = buffer[0];
HAL_GPIO_WritePin(LED_GPIO_PORT, LED_PIN_NUMLOCK,
(led_state & 0x01) ? GPIO_PIN_SET : GPIO_PIN_RESET);
HAL_GPIO_WritePin(LED_GPIO_PORT, LED_PIN_CAPSLOCK,
(led_state & 0x02) ? GPIO_PIN_SET : GPIO_PIN_RESET);
HAL_GPIO_WritePin(LED_GPIO_PORT, LED_PIN_SCROLLLOCK,
(led_state & 0x04) ? GPIO_PIN_SET : GPIO_PIN_RESET);
}
if (report_type == HID_REPORT_TYPE_FEATURE && bufsize >= 1) {
/* Feature report received — save configuration to flash */
uint8_t config_byte = buffer[0];
flash_config_write(CONFIG_KEY_HID, &config_byte, 1);
}
}
/* Host can read back Feature report via GET_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;
if (report_type == HID_REPORT_TYPE_FEATURE && reqlen >= 1) {
/* Read config from flash and return it */
flash_config_read(CONFIG_KEY_HID, buffer, 1);
return 1;
}
return 0;
}
Multi-Report HID with Consumer Control
Using multiple Report IDs in a single HID interface is a powerful technique: you get keyboard, mouse, and media key functionality all from one USB interface, without needing a composite device or multiple endpoints. The host driver routes each report to the appropriate OS subsystem based on the Report ID and usage pages.
Three-Report Descriptor with Consumer Control
/* Three reports: 1=keyboard, 2=mouse, 3=consumer control (media keys) */
uint8_t const desc_hid_report_triple[] = {
/* ── Report ID 1: Keyboard ─────────────────────────────── */
HID_REPORT_DESC_KEYBOARD( HID_REPORT_ID(1) ),
/* ── Report ID 2: Mouse ─────────────────────────────────── */
HID_REPORT_DESC_MOUSE( HID_REPORT_ID(2) ),
/* ── Report ID 3: Consumer Control (media keys) ──────────── */
0x05, 0x0C, /* Usage Page (Consumer) */
0x09, 0x01, /* Usage (Consumer Control) */
0xA1, 0x01, /* Collection (Application) */
0x85, 0x03, /* Report ID (3) */
0x15, 0x00, /* Logical Minimum (0) */
0x25, 0x01, /* Logical Maximum (1) */
0x75, 0x01, /* Report Size (1 bit) */
0x95, 0x10, /* Report Count (16 keys) */
/* Media key usages */
0x09, 0xB5, /* Usage (Scan Next Track) */
0x09, 0xB6, /* Usage (Scan Previous Track) */
0x09, 0xB7, /* Usage (Stop) */
0x09, 0xCD, /* Usage (Play/Pause) */
0x09, 0xE2, /* Usage (Mute) */
0x09, 0xE9, /* Usage (Volume Increment) */
0x09, 0xEA, /* Usage (Volume Decrement) */
0x09, 0x23, /* Usage (WWW Home) */
0x09, 0x94, /* Usage (My Computer) */
0x09, 0x92, /* Usage (Calculator) */
0x09, 0x2A, /* Usage (WWW Favorites) */
0x09, 0x21, /* Usage (WWW Search) */
0x09, 0x26, /* Usage (WWW Stop) */
0x09, 0x24, /* Usage (WWW Back) */
0x09, 0x31, /* Usage (WWW Forward) */
0x09, 0x27, /* Usage (WWW Refresh) */
0x81, 0x02, /* Input (Data, Variable, Absolute) */
0xC0 /* End Collection */
};
/* Media key bitmask (matches usage order above) */
typedef enum {
MEDIA_NEXT_TRACK = (1 << 0),
MEDIA_PREV_TRACK = (1 << 1),
MEDIA_STOP = (1 << 2),
MEDIA_PLAY_PAUSE = (1 << 3),
MEDIA_MUTE = (1 << 4),
MEDIA_VOL_UP = (1 << 5),
MEDIA_VOL_DOWN = (1 << 6),
MEDIA_WWW_HOME = (1 << 7),
} media_key_t;
/* Send a media key press and release */
void media_key_press(media_key_t key) {
if (!tud_hid_ready()) return;
/* Key down */
uint16_t report = (uint16_t)key;
tud_hid_report(3, &report, sizeof(report));
/* Small delay, then key up */
board_delay(10);
report = 0;
tud_hid_report(3, &report, sizeof(report));
}
/* Dispatch reports from main loop based on which device has new data */
static uint8_t s_active_report = 0; /* 1=kb, 2=mouse, 3=media */
void hid_triple_task(void) {
if (!tud_hid_ready()) return;
switch (s_active_report) {
case 1: {
/* Send any pending keyboard data */
uint8_t keycodes[6] = {0};
tud_hid_keyboard_report(1, 0, keycodes);
break;
}
case 2:
tud_hid_mouse_report(2, 0, 0, 0, 0, 0);
break;
case 3: {
uint16_t report = 0;
tud_hid_report(3, &report, sizeof(report));
break;
}
}
}
HID Descriptor Tools & Debugging
Writing report descriptors by hand is error-prone. The following tools are essential for authoring, validating, and debugging HID descriptors.
Tool Reference
| Tool | Platform | Purpose |
| HID Descriptor Tool (dt2_4.exe) | Windows | Official USB.org GUI tool for authoring descriptors. Validates syntax and generates C arrays. Download from usb.org/document-library. |
| eleccelerator.com HID parser | Web (any) | Paste a C array or hex string; it parses and annotates every item. Excellent for understanding existing descriptors. |
| usbhid-dump | Linux | Reads back the actual report descriptor from a connected device: sudo usbhid-dump -d cafe:4002 |
| hidtest.exe / HIDTestApp | Windows | Microsoft HID test tool — sends/receives reports, displays descriptor parse tree. |
| Wireshark + USBPcap | Windows/Linux | Captures USB traffic including HID reports. Filter: usb.transfer_type == 0x01 for interrupt transfers. |
Common Report Descriptor Mistakes
- Missing End Collection: Every
Collection must have a matching End Collection (0xC0). The host parser will reject the descriptor silently if these are mismatched.
- Wrong Report Size × Count: The total bits described by
Report Size × Report Count must equal the actual report payload. If your report is 8 bytes (64 bits) but the descriptor sums to 63 bits, the host may reject or misparse it.
- Logical Min > Logical Max: For signed values, Logical Minimum must be expressed correctly. For -127 to 127: use
0x15, 0x81 (signed byte representation) not 0x15, 0xFF.
- Wrong wDescriptorLength: The HID descriptor field
wDescriptorLength must exactly match sizeof(desc_hid_report). Off-by-one errors here cause the host to read garbage or fail silently.
- Report ID in boot protocol: If
bInterfaceProtocol is keyboard (0x01) or mouse (0x02) and the device supports boot protocol, the boot-mode report must NOT include a Report ID byte prefix — the 8-byte or 3-byte format is fixed.
Reading Back Your Descriptor with usbhid-dump
# Install usbhid-dump (part of usbutils on most distros)
sudo apt install usbutils # Debian/Ubuntu
# List connected HID devices
sudo usbhid-dump -l
# Dump the report descriptor for a specific device (replace VID:PID)
sudo usbhid-dump -d cafe:4002 -e descriptor
# Stream live HID reports (shows raw bytes of every IN report)
sudo usbhid-dump -d cafe:4002 -e stream -t 10
Practical Exercises
Beginner: Button-Triggered Keyboard
Wire a tactile button to a GPIO pin. When the button is pressed, your device sends the HID keyboard report for "Hello World\n" — one keycode per polling interval. Requirements: debounce the button in firmware (10ms debounce window); send key-up between each key-down; verify in a text editor that the typed string appears correctly with proper capitalization of 'H', 'W' (shift modifier).
/* Skeleton: debounced button → keyboard output */
static bool s_btn_prev = false;
static bool s_typing = false;
void button_keyboard_task(void) {
bool btn_now = (HAL_GPIO_ReadPin(BTN_GPIO, BTN_PIN) == GPIO_PIN_RESET);
if (btn_now && !s_btn_prev) {
/* Rising edge detected after debounce */
s_typing = true;
s_type_index = 0;
s_key_released = true;
}
s_btn_prev = btn_now;
if (s_typing) {
keyboard_task(); /* from Section 3 */
if (s_type_string[s_type_index] == '\0') s_typing = false;
}
}
Intermediate: WebHID Gamepad
Build a USB gamepad with two analog axes (connected to ADC channels) and 8 digital buttons (connected to GPIO pins). Implement the TinyUSB gamepad descriptor. Verify using the browser's Gamepad API at gamepad-tester.com. Requirements: ADC values must be scaled to int8_t range (-127 to 127); button debounce of 5ms; send a report every polling interval even if no state changed (prevents host timeout on some OSes).
Advanced: IMU Sensor with Real-Time Python Visualization
Connect an I2C IMU (e.g., MPU6050, ICM42688, or LSM6DS3) to your MCU. Implement the vendor-defined IMU HID descriptor from Section 6. On the device side: read the sensor via I2C DMA at 100Hz; send HID IN reports at 100Hz; implement the Feature report to allow the host to configure sample rate and measurement range. On the host side: write a Python script using the hid library that reads quaternion data (computed by a simple Mahony or Madgwick filter running on the MCU) and displays a 3D orientation visualization using matplotlib or OpenGL.
Generate Your HID Design Document
Document your USB HID device design — report descriptors, polling interval, MCU target, and implementation notes — and export a structured design specification for sharing with your team.
Conclusion & Next Steps
USB HID is the most powerful zero-driver-install class in the USB ecosystem. Here is a summary of what we covered:
- Architecture: HID uses interrupt endpoints with a host-defined polling interval. A single interface contains the HID descriptor (which references the report descriptor) and one or two interrupt endpoints. No custom driver is ever needed on Windows, Linux, or macOS.
- Report descriptor language: A compact bytecode format defining every bit of every report. Global items set context (usage page, logical range, report size/count). Local items assign usage. Main items commit fields (Input, Output, Feature, Collection).
- Keyboard: 8-byte boot format with modifier byte + reserved byte + 6 keycodes. Use
tud_hid_keyboard_report() in TinyUSB. Retarget typed string output by sending key-down / key-up pairs.
- Mouse: Relative X/Y/wheel deltas. Use
tud_hid_mouse_report(). Absolute positioning requires declaring Usage (X/Y) as Physical with absolute flag.
- Gamepad: TinyUSB's
HID_REPORT_DESC_GAMEPAD macro handles the descriptor. Works with browser WebHID API and native game input APIs.
- Custom sensor HID: Vendor usage page (0xFF00) enables arbitrary data formats. Host reads raw bytes using
hidapi / Python hid library. Feature reports allow bi-directional configuration.
- Boot protocol: Required for BIOS/UEFI support. Declare
bInterfaceSubClass=0x01 and handle SET_PROTOCOL switching in tud_hid_set_protocol_cb().
- Multi-report: Multiple Report IDs in one interface give you keyboard + mouse + media keys from a single HID descriptor — no composite device needed.
Next Article: Part 8 — USB Mass Storage. We implement a USB MSC device from scratch: SCSI command parsing, integrating FATFS, implementing a RAM disk for rapid prototyping, and connecting to real flash or SD card storage.
Other Articles in This Series
Part 6: CDC Virtual COM Port
ACM control requests, bulk endpoint design, receive pipeline, baud rate callbacks, and CDC-to-UART bridging with DMA.
Read Article
Part 8: Mass Storage (MSC)
SCSI command set, FATFS integration, RAM disk implementation, and connecting to SD card storage via SPI/SDIO.
Read Article
Part 9: Composite Devices
Combine CDC + HID + MSC in one device. IAD descriptor assembly, interface association, and OS compatibility rules.
Read Article