Embedded HandbookApps
All sections › III. Peripherals
STM32F103, STM32F407 · level intermediate · 24 min read
Open in the interactive handbook

20в. USB HID in practice: the report descriptor and a composite device

Section 20 called HID a class that needs no drivers, section 20а showed a host that reads a boot keyboard. Here — the device side. A HID device sends the host short reports, and their format is described by the report descriptor: a small program made of bytes, which the host parses on connection. We will work through this language, read the mouse from the ST library byte by byte with our own parser, build a boot keyboard and a composite device — a keyboard, a mouse and volume keys on one interface — and write the code that sends reports.

Sources: the ST USB Device library from the STM32Cube_FW_F1 V1.8.7 and STM32Cube_FW_F4 V1.28.0 packages (the HID and Custom HID classes, the CustomHID_Standalone example for the STM3210E_EVAL board), the ST USB Host library (usbh_hid_keybd.c, usbh_hid.c), MDK-Middleware 8.1.0 (usb_hid.h, the USBD_User_HID_Mouse.c template, USBD_Config_HID.h), the Zephyr tree 4.4.2 (include/zephyr/usb/class/hid.h, usb_ch9.h) and hidusage.h from the Windows SDK 10.0.19041 — of these sources only it has the volume key codes. The HID 1.11 specification and the HUT usage tables are not quoted: all codes are taken from these headers.

What it is called in the documentation

Original Meaning
report, input / output / feature report a report: input (device → host), output (host → device), feature report
report descriptor the description of all reports of the device
item: main, global, local a descriptor item: main, global, local
Usage Page, Usage a usage page; a usage — what a field means
Report Size, Report Count field size in bits; number of fields
Report ID the report number: the first byte when there are several reports
collection (application, physical) a collection: application, physical
boot protocol, report protocol boot protocol (fixed format), report protocol (per the descriptor)
modifier, keycode a modifier key (Ctrl, Shift…); a key code
consumer control consumer keys: volume, pause
bInterval the period at which the host polls an interrupt endpoint

20в.1 A report and its description

A HID device sends data in reports through an interrupt IN endpoint: a mouse — movement and buttons, a keyboard — modifiers and codes of pressed keys. The host polls the endpoint once every bInterval. Output reports (the Caps Lock LEDs) go back — through an OUT endpoint or with a SET_REPORT request on endpoint zero; the ST Custom HID class accepts both paths.

What is in a report the device describes with the report descriptor. The host reads it with a GET_DESCRIPTOR request of type 0x22 (HID_REPORT_DESC in usbd_hid.h, USB_DESC_HID_REPORT in Zephyr's hid.h), and learns its length in advance from the HID descriptor (type 0x21) inside the configuration descriptor — the wDescriptorLength field (wItemLength in the ST comments). The ST class returns MIN(USBD_CUSTOM_HID_REPORT_DESC_SIZE, req->wLength) bytes: it takes the length from the macro, not from the array.

If there are several reports, each has its own number — the Report ID, and the number goes as the first byte. That is what the ST example CustomHID_Standalone does: the button report is SendBuffer[0] = KEY_REPORT_ID, SendBuffer[1] is the state, USBD_CUSTOM_HID_SendReport(…, SendBuffer, 2); a received output report goes to the handler as a "number, byte" pair (OutEvent(Report_buf[0], Report_buf[1])).

The composite HID device from 20в.5: the keyboard input report is 9 bytes with number 1, the LED output report is 2 bytes with number 1, the mouse is 5 bytes with number 2, the consumer keys are 2 bytes with number 3; the number is always the first byte, there is one IN endpoint


20в.2 The descriptor language: items and their codes

A descriptor is a sequence of items. The first byte of an item is the prefix: the tag in the upper four bits, the type in two, the data size in the two low bits. That is how Zephyr's HID_ITEM(bTag, bType, bSize) macro in hid.h builds it. The size code 0, 1, 2, 3 means 0, 1, 2 and 4 data bytes (HID_LOGICAL_MIN32 sets code 3 and writes four bytes). Multi-byte data goes low byte first: HID_USAGE_PAGE16(page) in Zephyr writes (uint8_t)page first, HID_LogicalMaxS(x) in MDK — x & 0xFFU.

Item Type Tag Code with 1 data byte
Input main (0) 0x8 0x81
Output main (0) 0x9 0x91
Feature main (0) 0xB 0xB1
Collection main (0) 0xA 0xA1
Usage Page global (1) 0x0 0x05
Logical Minimum global (1) 0x1 0x15
Logical Maximum global (1) 0x2 0x25
Report Size global (1) 0x7 0x75
Report ID global (1) 0x8 0x85
Report Count global (1) 0x9 0x95
Usage local (2) 0x0 0x09
Usage Minimum local (2) 0x1 0x19
Usage Maximum local (2) 0x2 0x29

End Collection is 0xC0, with no data. The same codes are produced by the MDK macros (HID_Input(x) — 0x81U,x, HID_ReportID(x) — 0x85U,x) and by the tag table in usbh_hid.h of the ST USB Host library.

It works as a small language with state:

The report size is the sum of Report Size × Report Count of all main items of one type and number, divided by 8, plus one byte for the number if there are numbers.


20в.3 The mouse from the ST library byte by byte

The model _verify/hid20c/hid20c.py is our own parser following the rules of 20в.2. It takes the HID_MOUSE_ReportDesc array straight from usbd_hid.c (in the F1 V1.8.7 and F4 V1.28.0 packages the bytes are identical) and prints a tree:

05 01             Usage Page (Generic Desktop)
09 02             Usage (Mouse)
A1 01             Collection (Application)
09 01               Usage (Pointer)
A1 00               Collection (Physical)
05 09                 Usage Page (Button)
19 01                 Usage Minimum (0x01)
29 03                 Usage Maximum (0x03)
15 00                 Logical Minimum (0)
25 01                 Logical Maximum (1)
95 03                 Report Count (3)
75 01                 Report Size (1)
81 02                 Input (Data,Var,Abs; 3 × 1 бит, биты 0…2)
95 01                 Report Count (1)
75 05                 Report Size (5)
81 01                 Input (Const,Array,Abs; 1 × 5 бит, биты 3…7)
05 01                 Usage Page (Generic Desktop)
09 30                 Usage (X)
09 31                 Usage (Y)
09 38                 Usage (Wheel)
15 81                 Logical Minimum (-127)
25 7F                 Logical Maximum (127)
75 08                 Report Size (8)
95 03                 Report Count (3)
81 06                 Input (Data,Var,Rel; 3 × 8 бит, биты 8…31)
C0                  End Collection
09 3C               Usage (Motion Wakeup)
05 FF               Usage Page (0xFF)
09 01               Usage (0x01)
15 00               Logical Minimum (0)
25 01               Logical Maximum (1)
75 01               Report Size (1)
95 02               Report Count (2)
B1 22               Feature (Data,Var,Abs; 2 × 1 бит, биты 0…1)
75 06               Report Size (6)
95 01               Report Count (1)
B1 01               Feature (Const,Array,Abs; 1 × 6 бит, биты 2…7)
C0                End Collection

The result of the parse: 74 bytes, 38 items, 2 collections. The input report is 32 bits, 4 bytes: a button byte (3 button bits and 5 padding bits), then X, Y and the wheel as signed bytes, from −127 to 127, relative. The feature report is 1 byte. A check against what the library declared: HID_MOUSE_REPORT_DESC_SIZE = 74 — the length matches, HID_EPIN_SIZE = 4 — the input report size matches.

The last 22 bytes are a feature report with the Motion Wakeup usage (0x3C, HID_USAGE_GENERIC_MOTION_WAKEUP in MDK). The MDK mouse template USBD_User_HID_Mouse.c does not have them: the parser got 52 bytes — exactly as many as stated in the template comment — and the same 4 bytes of input. Zephyr's HID_MOUSE_REPORT_DESC(3) macro gives the same 52 bytes from the same items, only Report Size and Report Count are in a different order: for global items the order does not matter, what matters is that they are set before the main item.


20в.4 The keyboard: the boot protocol and key codes

The boot keyboard report is fixed in advance so that a host that does not read the descriptor can understand it. That is how the ST USB Host library works: it requests the report descriptor but does not parse it, sets the boot protocol (USBH_HID_SetProtocol(phost, 0U)) and reads 8 bytes at fixed offsets: USBH_HID_KEYBD_REPORT_SIZE = 8, the modifiers are the bits of byte 0, the codes are six bytes from byte 2, from 0 to 101. A device whose bInterfaceProtocol is neither 1 (keyboard) nor 2 (mouse) it does not accept: "Protocol not supported".

The descriptor of such a report is Zephyr's HID_KEYBOARD_REPORT_DESC() macro. The parser: 63 bytes, 32 items, an input report of 64 bits — 8 bytes, an output report of 1 byte. The input layout:

Byte 0 1 2…7
what modifiers, 8 bits reserved, constant up to six codes of pressed keys, array

The modifier bits (enum hid_kbd_modifier in hid.h): left Ctrl — 0x01, left Shift — 0x02, left Alt — 0x04, left GUI — 0x08, the right ones — 0x10, 0x20, 0x40, 0x80. The output byte is the LEDs: Num Lock 0x01, Caps Lock 0x02, Scroll Lock 0x04. Key codes (enum hid_kbd_code; the same in ST's usbh_hid_keybd.h and MDK's usb_hid.h): A — 0x04, …, Z — 0x1D, 1 — 0x1E, …, 0 — 0x27, Enter — 0x28, space — 0x2C. A capital letter is the same code with Shift: "H" is 02 00 0B 00 00 00 00 00.

A press is a report with the code, a release is a report without it. The model turns "Hello, STM32!" (13 characters) into 26 reports, and with the HID_KEYBRD_Key and HID_KEYBRD_ShiftKey tables of the ST USB Host library (the QWERTY layout) turns them back — and gets the same text. The host in the model counts as a character a code that was not in the previous report; that is why two "l" in a row need a release between them.

The typing speed is set by polling: one report per poll, two reports per character. With polling once every 1 ms the string goes out in 26 ms — 500 characters per second, with 10 ms — in 260 ms, 50 characters per second.


20в.5 A composite device: report numbers and consumer keys

The composite device from section 20 is several interfaces with different classes. For HID there is a simpler path: one interface, one IN endpoint and several application collections, each with its own Report ID. The host sees a keyboard, a mouse and a volume remote at once, and in the ST library the Custom HID class is enough — it takes the report descriptor from the program.

Our own descriptor in _verify/hid20c/hid20c.c has three collections: a keyboard (number 1: an 8-byte input as in the boot one, the output is the LEDs), a mouse (number 2) and consumer keys (number 3). The last block:

    0x05, 0x0C,             /* Usage Page (Consumer)                     */
    0x09, 0x01,             /* Usage (Consumer Control)                  */
    0xA1, 0x01,             /* Collection (Application)                  */
    0x85, RID_CONSUMER,     /*   Report ID (3)                           */
    0x09, 0xE9, 0x09, 0xEA, /*   Usage: Volume Increment, Decrement      */
    0x09, 0xE2, 0x09, 0xCD, /*   Usage: Mute, Play/Pause                 */
    0x15, 0x00, 0x25, 0x01, /*   Logical Min/Max: 0 … 1                  */
    0x75, 0x01, 0x95, 0x04, /*   Report Size 1, Report Count 4           */
    0x81, 0x02,             /*   Input (Data, Var, Abs): четыре клавиши  */
    0x95, 0x04,             /*   Report Count 4                          */
    0x81, 0x01,             /*   Input (Const): добивка                  */
    0xC0,                   /* End Collection                            */

The Consumer page is 0x0C (HID_USAGE_PAGE_CONSUMER in MDK and in hidusage.h), the usages are from hidusage.h: volume up 0xE9, volume down 0xEA, mute 0xE2, pause 0xCD. Four bits — four keys; for the padding Report Size is not repeated: the 1 from the line above applies.

The parser on the bytes from the object file (the data section that armclang put into hid20c.o): 150 bytes, 77 items, 4 collections (three application ones and a physical one in the mouse), no errors. The reports:

Number Kind Data bits Bytes with the number
1 input, keyboard 64 9
1 output, LEDs 8 2
2 input, mouse 32 5
3 input, consumer 8 2

Hence the class setup: USBD_CUSTOM_HID_REPORT_DESC_SIZE = 150, the IN endpoint at least 9 bytes, the output report buffer USBD_CUSTOMHID_OUTREPORT_BUF_SIZE = 2 — exactly the number and the LED byte, which is also the default value.

The ST example CustomHID_Standalone uses the same technique: 163 bytes, seven numbers, each report is 2 bytes with the number, hence CUSTOM_HID_EPIN_SIZE and CUSTOM_HID_EPOUT_SIZE of 2 each. The parser also shows what the example relies on: in the block for number 5 Report Count is not set — the 1 from the block for number 4 applies.

Such a device does not declare itself as a boot device: Custom HID sets subclass 0 and protocol 0. A host that needs the boot protocol — the ST USB Host library or, probably, the BIOS setup — will not see the keyboard.


20в.6 Code: a report queue, bInterval and latency

The file _verify/hid20c/hid20c.c is built by armclang for the STM32F103 (the library from the F1 V1.8.7 package) and the STM32F407 (F4 V1.28.0) without warnings. The reports and the compile-time size checks:

struct kbd_report      { uint8_t id, mod, reserved, keys[6]; };
struct mouse_report    { uint8_t id, buttons; int8_t x, y, wheel; };
struct consumer_report { uint8_t id, bits; };

enum { CC_VOL_UP = 1u << 0, CC_VOL_DOWN = 1u << 1, CC_MUTE = 1u << 2, CC_PLAY_PAUSE = 1u << 3 };

_Static_assert(sizeof(hid_report_desc) == USBD_CUSTOM_HID_REPORT_DESC_SIZE,
               "USBD_CUSTOM_HID_REPORT_DESC_SIZE не равен длине дескриптора");
_Static_assert(sizeof(struct kbd_report) == 9u && sizeof(struct mouse_report) == 5u
               && sizeof(struct consumer_report) == 2u, "размеры отчётов: 9, 5 и 2 байта с номером");
_Static_assert(CUSTOM_HID_EPIN_SIZE >= sizeof(struct kbd_report),
               "точка IN меньше отчёта клавиатуры: поправить CUSTOM_HID_EPIN_SIZE");
_Static_assert(USBD_CUSTOMHID_OUTREPORT_BUF_SIZE >= 2u, "отчёт светодиодов: номер + байт");

The third check catches a difference between the packages. In F4 V1.28.0 the endpoint size CUSTOM_HID_EPIN_SIZE is wrapped in #ifndef, and it is set in usbd_conf.h. In F1 V1.8.7 it is a plain #define CUSTOM_HID_EPIN_SIZE 0x02U, and without editing the class header the build stops on this line ("2 >= 9"). This is intended: otherwise the endpoint would stay at 2 bytes, and the keyboard report would not fit into it.

Sending is a queue from which the main loop takes one report at a time when the endpoint is free:

void hid_kbd_tap(uint8_t mod, uint8_t key)
{
    struct kbd_report r = { RID_KBD, mod, 0, { key } };
    if (!q_room(2u))                                /* нажатие без отпускания — залипшая клавиша */
        return;
    q_put(&r, sizeof r);
    memset(&r.mod, 0, sizeof r - 1u);               /* отпускание: всё, кроме номера, — нули */
    q_put(&r, sizeof r);
}
void hid_task(void)
{
    static uint8_t tx[2][sizeof(struct kbd_report)]; /* один в передаче, в другой пишем */
    static uint32_t cur;
    const struct hid_slot *s;
    uint8_t st;

    if (hUsbDeviceFS.dev_state != USBD_STATE_CONFIGURED || q_tail == q_head)
        return;                                     /* до SET_CONFIGURATION SendReport молча не шлёт */
    s = &q[q_tail % Q_LEN];
    memcpy(tx[cur], s->data, s->len);
    NVIC_DisableIRQ(USB_IRQ);
    st = USBD_CUSTOM_HID_SendReport(&hUsbDeviceFS, tx[cur], s->len);
    NVIC_EnableIRQ(USB_IRQ);
    if (st == (uint8_t)USBD_OK) {                   /* USBD_BUSY: прошлый ещё не забран — позже */
        cur ^= 1u;
        q_tail++;
    }
}

Why it is done this way. USBD_CUSTOM_HID_SendReport does not copy the report but passes a pointer — the buffer must not be touched until the host has taken it; that is why there are two buffers. While the endpoint is busy, the function returns USBD_BUSY, and the report stays in the queue. Before the device is configured it sends nothing but returns USBD_OK — hence the dev_state check. The USB interrupt is disabled for the duration of the call, as in 20.7: the library is not called from two places at once. A queue of 16 reports of 10 bytes takes 160 bytes of RAM.

The output report is received by hid_out_event. For the F103 armclang with -O2 turns it into 7 instructions — a comparison of the number and a byte write:

CMP      r0,#1
ITTT     EQ
MOVWEQ   r0,#:LOWER16: kbd_leds
MOVTEQ   r0,#:UPPER16: kbd_leds
STRBEQ   r1,[r0,#0]
MOVS     r0,#0
BX       lr

For the F407 it also contains a call to USBD_CUSTOM_HID_ReceivePacket: the class from F4 V1.28.0 does not re-arm the OUT endpoint by itself after reception, the program does it (as in the ST example too). The class from F1 V1.8.7 re-arms it by itself.

Polling. bInterval in the endpoint descriptor is the period at which the host polls it. At full speed it is in milliseconds, 1…255 (USB_FS_INT_EP_INTERVAL in Zephyr, a hint in MDK's USBD_Config_HID.h). At high speed it is 2^(bInterval−1) × 125 µs: the ST HID class has HID_HS_BINTERVAL = 7, that is 64 × 125 µs = 8 ms. An event waits for the nearest poll — on average half a period, in the worst case the whole period:

Setting bInterval average wait worst case
ST HID class, HID_FS_BINTERVAL 10 ms 5.0 ms 10 ms
ST Custom HID class, CUSTOM_HID_FS_BINTERVAL 5 ms 2.5 ms 5 ms
hid20c, usbd_conf.h 1 ms 0.5 ms 1 ms

The host may also poll less often than declared: the ST USB Host library does not poll more often than HID_MIN_POLL = 10 ms, and bInterval = 1 becomes 10 ms in it.


20в.7 Pitfalls

A key got stuck, double letters disappear. The ST HID class: while the endpoint is busy, USBD_HID_SendReport silently drops the report and returns USBD_OK. Code that sends a press and immediately a release loses the release. Model: a character every 20 ms, polling 10 ms — of 26 reports 13 got through, "Hello, STM32!" turned into "Helo, STM32!", and the last key stayed pressed. You need a queue, as in 20в.6.

The keyboard report does not get through, the mouse works. The IN endpoint is smaller than the report. In F1 V1.8.7 CUSTOM_HID_EPIN_SIZE = 2 and without #ifndef — it is edited in the class header.

The host sees garbage or does not see the device. USBD_CUSTOM_HID_REPORT_DESC_SIZE does not equal the array length: the class returns as many bytes as the macro says — a truncated descriptor or a tail of someone else's memory. A _Static_assert saves you.

The report is one byte longer than the host expects. The descriptor has a Report ID, but the number was not put as the first byte — or the other way round. If the number is in the descriptor, it is in every report.

The bit total is not divisible by 8. The padding was forgotten: after three button bits you need a 5-bit Const field, after five LEDs — a 3-bit one.

Caps Lock on the F4 lit up once. In the Custom HID class from F4 V1.28.0 the class does not re-arm the OUT endpoint by itself: the output report handler in the program does it with a call to USBD_CUSTOM_HID_ReceivePacket; without it the next report will not arrive.

The vendor page is written backwards. In the ST example CustomHID_Standalone the bytes 06 FF 00 are labelled "Vendor Page: 0xFF00", but data goes low byte first — in the same place 26 FF 00 means 255 for them. The result is page 0x00FF, while vendor pages per hidusage.h are 0xFF00…0xFFFF; HID_UsagePageVendor(0x00) from MDK writes 06 00 FF.

The keyboard does not work on a simple host. A composite device with report numbers is not a boot device: the ST USB Host library and the like will not accept it. If you need a keyboard that works "everywhere" — a separate interface with subclass 1 and protocol 1 and an 8-byte report without a number.


20в.8 Summary

Full sources

hid20c.c — a composite HID device: keyboard, mouse and consumer keys.

/* hid20c.c — раздел 20в: составное HID-устройство на одном интерфейсе.
 * Клавиатура (отчёт 1), мышь (отчёт 2) и потребительские клавиши (отчёт 3)
 * поверх класса Custom HID библиотеки USB Device ST.
 * Сборка: armclang, STM32F103 (STM32Cube_FW_F1 V1.8.7) и STM32F407 (STM32Cube_FW_F4 V1.28.0).
 * На железе не запускался. */
#include <string.h>
#include "usbd_customhid.h"

#if defined(STM32F103xB)
#define USB_IRQ  USB_LP_CAN1_RX0_IRQn
#elif defined(STM32F407xx)
#define USB_IRQ  OTG_FS_IRQn
#endif

enum { RID_KBD = 1, RID_MOUSE = 2, RID_CONSUMER = 3 };

__ALIGN_BEGIN static uint8_t hid_report_desc[] __ALIGN_END = {
    0x05, 0x01,             /* Usage Page (Generic Desktop)              */
    0x09, 0x06,             /* Usage (Keyboard)                          */
    0xA1, 0x01,             /* Collection (Application)                  */
    0x85, RID_KBD,          /*   Report ID (1)                           */
    0x05, 0x07,             /*   Usage Page (Keyboard)                   */
    0x19, 0xE0, 0x29, 0xE7, /*   Usage Min/Max: Left Control … Right GUI */
    0x15, 0x00, 0x25, 0x01, /*   Logical Min/Max: 0 … 1                  */
    0x75, 0x01, 0x95, 0x08, /*   Report Size 1, Report Count 8           */
    0x81, 0x02,             /*   Input (Data, Var, Abs): модификаторы    */
    0x75, 0x08, 0x95, 0x01, /*   Report Size 8, Report Count 1           */
    0x81, 0x01,             /*   Input (Const): резервный байт           */
    0x05, 0x08,             /*   Usage Page (LEDs)                       */
    0x19, 0x01, 0x29, 0x05, /*   Usage Min/Max: Num Lock … Kana          */
    0x75, 0x01, 0x95, 0x05, /*   Report Size 1, Report Count 5           */
    0x91, 0x02,             /*   Output (Data, Var, Abs): светодиоды     */
    0x75, 0x03, 0x95, 0x01, /*   Report Size 3, Report Count 1           */
    0x91, 0x01,             /*   Output (Const): добивка до байта        */
    0x05, 0x07,             /*   Usage Page (Keyboard)                   */
    0x19, 0x00, 0x29, 0x65, /*   Usage Min/Max: коды 0 … 101             */
    0x15, 0x00, 0x25, 0x65, /*   Logical Min/Max: 0 … 101                */
    0x75, 0x08, 0x95, 0x06, /*   Report Size 8, Report Count 6           */
    0x81, 0x00,             /*   Input (Data, Array): нажатые клавиши    */
    0xC0,                   /* End Collection                            */

    0x05, 0x01,             /* Usage Page (Generic Desktop)              */
    0x09, 0x02,             /* Usage (Mouse)                             */
    0xA1, 0x01,             /* Collection (Application)                  */
    0x85, RID_MOUSE,        /*   Report ID (2)                           */
    0x09, 0x01,             /*   Usage (Pointer)                         */
    0xA1, 0x00,             /*   Collection (Physical)                   */
    0x05, 0x09,             /*     Usage Page (Button)                   */
    0x19, 0x01, 0x29, 0x03, /*     Usage Min/Max: кнопки 1 … 3           */
    0x15, 0x00, 0x25, 0x01, /*     Logical Min/Max: 0 … 1                */
    0x75, 0x01, 0x95, 0x03, /*     Report Size 1, Report Count 3         */
    0x81, 0x02,             /*     Input (Data, Var, Abs): кнопки        */
    0x75, 0x05, 0x95, 0x01, /*     Report Size 5, Report Count 1         */
    0x81, 0x01,             /*     Input (Const): добивка                */
    0x05, 0x01,             /*     Usage Page (Generic Desktop)          */
    0x09, 0x30, 0x09, 0x31, /*     Usage (X), Usage (Y)                  */
    0x09, 0x38,             /*     Usage (Wheel)                         */
    0x15, 0x81, 0x25, 0x7F, /*     Logical Min/Max: −127 … 127           */
    0x75, 0x08, 0x95, 0x03, /*     Report Size 8, Report Count 3         */
    0x81, 0x06,             /*     Input (Data, Var, Rel): сдвиги        */
    0xC0,                   /*   End Collection                          */
    0xC0,                   /* End Collection                            */

    0x05, 0x0C,             /* Usage Page (Consumer)                     */
    0x09, 0x01,             /* Usage (Consumer Control)                  */
    0xA1, 0x01,             /* Collection (Application)                  */
    0x85, RID_CONSUMER,     /*   Report ID (3)                           */
    0x09, 0xE9, 0x09, 0xEA, /*   Usage: Volume Increment, Decrement      */
    0x09, 0xE2, 0x09, 0xCD, /*   Usage: Mute, Play/Pause                 */
    0x15, 0x00, 0x25, 0x01, /*   Logical Min/Max: 0 … 1                  */
    0x75, 0x01, 0x95, 0x04, /*   Report Size 1, Report Count 4           */
    0x81, 0x02,             /*   Input (Data, Var, Abs): четыре клавиши  */
    0x95, 0x04,             /*   Report Count 4                          */
    0x81, 0x01,             /*   Input (Const): добивка                  */
    0xC0,                   /* End Collection                            */
};

struct kbd_report      { uint8_t id, mod, reserved, keys[6]; };
struct mouse_report    { uint8_t id, buttons; int8_t x, y, wheel; };
struct consumer_report { uint8_t id, bits; };

enum { CC_VOL_UP = 1u << 0, CC_VOL_DOWN = 1u << 1, CC_MUTE = 1u << 2, CC_PLAY_PAUSE = 1u << 3 };

_Static_assert(sizeof(hid_report_desc) == USBD_CUSTOM_HID_REPORT_DESC_SIZE,
               "USBD_CUSTOM_HID_REPORT_DESC_SIZE не равен длине дескриптора");
_Static_assert(sizeof(struct kbd_report) == 9u && sizeof(struct mouse_report) == 5u
               && sizeof(struct consumer_report) == 2u, "размеры отчётов: 9, 5 и 2 байта с номером");
_Static_assert(CUSTOM_HID_EPIN_SIZE >= sizeof(struct kbd_report),
               "точка IN меньше отчёта клавиатуры: поправить CUSTOM_HID_EPIN_SIZE");
_Static_assert(USBD_CUSTOMHID_OUTREPORT_BUF_SIZE >= 2u, "отчёт светодиодов: номер + байт");

#define Q_LEN 16u                                   /* степень двойки */

struct hid_slot { uint8_t len; uint8_t data[sizeof(struct kbd_report)]; };

static struct hid_slot q[Q_LEN];
static uint32_t q_head, q_tail, q_lost;             /* всё зовётся из главного цикла */

extern USBD_HandleTypeDef hUsbDeviceFS;

static int q_room(uint32_t n)
{
    if (Q_LEN - (q_head - q_tail) >= n)
        return 1;
    q_lost++;
    return 0;
}

static void q_put(const void *r, uint8_t len)
{
    struct hid_slot *s = &q[q_head % Q_LEN];
    s->len = len;
    memcpy(s->data, r, len);
    q_head++;
}

void hid_kbd_tap(uint8_t mod, uint8_t key)
{
    struct kbd_report r = { RID_KBD, mod, 0, { key } };
    if (!q_room(2u))                                /* нажатие без отпускания — залипшая клавиша */
        return;
    q_put(&r, sizeof r);
    memset(&r.mod, 0, sizeof r - 1u);               /* отпускание: всё, кроме номера, — нули */
    q_put(&r, sizeof r);
}

void hid_mouse_move(uint8_t buttons, int8_t dx, int8_t dy, int8_t wheel)
{
    struct mouse_report r = { RID_MOUSE, buttons, dx, dy, wheel };
    if (q_room(1u))
        q_put(&r, sizeof r);
}

void hid_consumer_tap(uint8_t bits)
{
    struct consumer_report r = { RID_CONSUMER, bits };
    if (!q_room(2u))
        return;
    q_put(&r, sizeof r);
    r.bits = 0;
    q_put(&r, sizeof r);
}

void hid_task(void)
{
    static uint8_t tx[2][sizeof(struct kbd_report)]; /* один в передаче, в другой пишем */
    static uint32_t cur;
    const struct hid_slot *s;
    uint8_t st;

    if (hUsbDeviceFS.dev_state != USBD_STATE_CONFIGURED || q_tail == q_head)
        return;                                     /* до SET_CONFIGURATION SendReport молча не шлёт */
    s = &q[q_tail % Q_LEN];
    memcpy(tx[cur], s->data, s->len);
    NVIC_DisableIRQ(USB_IRQ);
    st = USBD_CUSTOM_HID_SendReport(&hUsbDeviceFS, tx[cur], s->len);
    NVIC_EnableIRQ(USB_IRQ);
    if (st == (uint8_t)USBD_OK) {                   /* USBD_BUSY: прошлый ещё не забран — позже */
        cur ^= 1u;
        q_tail++;
    }
}

static volatile uint8_t kbd_leds;                   /* бит 0 — Num Lock, 1 — Caps Lock, 2 — Scroll Lock */

static int8_t hid_out_event(uint8_t report_id, uint8_t state)
{
    if (report_id == RID_KBD)
        kbd_leds = state;
#if defined(STM32F407xx)
    (void)USBD_CUSTOM_HID_ReceivePacket(&hUsbDeviceFS);  /* F4 V1.28.0: точку OUT перевзводит программа */
#endif
    return (int8_t)USBD_OK;
}

static int8_t hid_init(void)
{
    return (int8_t)USBD_OK;
}

static int8_t hid_deinit(void)
{
    return (int8_t)USBD_OK;
}

USBD_CUSTOM_HID_ItfTypeDef hid_fops = { hid_report_desc, hid_init, hid_deinit, hid_out_event };

uint8_t hid_leds(void)
{
    return kbd_leds;
}

uint32_t hid_lost(void)
{
    return q_lost;
}

Next: section 20г "A keyboard on STM32: dissecting QMK — matrix, debounce, layers and USB HID" — real keyboard firmware: the matrix, debounce, layers and reports.

Open in the interactive handbook