Finishing a Real HID Keyboard, from Keypress to Release

Turning key press and release events into HID reports and sending them to the host, plus what to watch out for with Boot Protocol and tap-style null reports.

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

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

Before you read this

Post 4 built the pipeline from a physical keypad to an event queue. This post picks up the rest: processing that runs from the event queue through the HID report layer to USB transport, and why the step that sounds simplest, calling a single function to send a report, actually hides more traps than you’d expect.

This post assumes you’re already comfortable with descriptor structure and the two concepts HID Report Descriptor and Boot Protocol, both covered in post 3, USB Descriptor. If you’re new and just want a quick reference for these ideas, check the short note: What Is the HID Keyboard Report Format?

Key points

  • CubeMX generates a mouse descriptor by default, even when you select HID Keyboard.
  • The composite device framework gets built in two steps: fix ST’s default class code first to confirm the HID keyboard logic works, then replace it with a hand-written composite file.
  • The 8-byte report only sends key state up to the host. Firmware supports Boot Protocol, but doesn’t handle Output reports, meaning it never receives LED state like Caps Lock back.
  • USBD_HID_SendReport() doesn’t give a meaningful signal for TX state when the endpoint is busy. The transport layer has to manage that itself with its own variable or flag.
  • Sending a null report right after a keypress (a “tap-style” approach) is how firmware avoids letting the OS apply its own typematic repeat.

Overview

This post picks up from the event queue and follows it all the way to a report actually appearing on the USB wire and the correct character showing up on the host, following this flow:

01 Queue

KeyEvent_t

Receives a key event: ON / OFF / REPEAT / ERROR.

02 Convert

Build report

Takes the event, looks it up in key_table, builds an 8-byte report.

03 Transport

Send report

Checks busy state before sending.

04 USB

Interrupt IN

The 8-byte report gets sent to the host each time.

05 Host

OS / BIOS

Reads the report according to the structure declared in the descriptor.

From the event queue to a character appearing on the host.

Convert and Transport are split into two separate modules to reduce coupling: changing how a report gets built doesn’t affect how it’s sent over USB, and vice versa.

The descriptor structure (Device, Configuration, HID Report Descriptor) was covered in post 3. The event queue structure (KeyEvent_t) and key_table were covered in post 4, so I won’t repeat either here.

Source map: from the default class to composite

Here’s the full directory structure of the real STM32CubeIDE project that ships with this series.

The Middlewares/, Keyboard/, and Usb/ folders are what this post covers. Any file inside these three folders without a comment is out of scope for this post.

Code CubeMX generates on its own also gets a comment, just aligned a little differently.

stm32g0-usb-device-lab/
├─ Core/                       // HAL init, system files generated by CubeMX
├─ Drivers/                    // CMSIS + STM32G0 HAL (vendor code)
├─ Middlewares/ST/.../Class/HID/
│  └─ usbd_hid.c                    // STEP 1: fix the descriptor directly
│                                      on the default ST HID class
├─ App/
│  └─ app_main.c
├─ Hardware/
├─ Keyboard/
│  ├─ key_detect.c / .h
│  ├─ key_event_queue.c / .h
│  ├─ key_table.c / .h
│  ├─ hid_keyboard_convert.c / .h   // consumes KeyEvent_t, tap-style null reports, macros
│  └─ hid_keyboard_report.c / .h    // the 8-byte report struct, assigns keycodes
├─ Usb/
│  ├─ usbd_composite.c              // STEP 2: fully replaces usbd_hid.c,
│  │                                   lays the groundwork for CDC and Vendor requests
│  └─ usb_hid_keyboard.c / .h       // the transport layer: checks busy state before sending
├─ USB_Device/                 // App + Target, descriptor and usbd_conf generated by CubeMX
└─ docs/
└─ tools/

Building out the USB layer happened in two steps:

  • Step 1, fix the default class: edit ST’s default HID file directly. The only goal at this stage is confirming the 8-byte report logic works correctly.

  • Step 2, move to composite: replace usbd_hid.c entirely with a hand-written composite file, laying the groundwork for adding CDC and Vendor requests in later posts.

This was done to minimize the risk of a bad descriptor or a bad class architecture, following the same principle from post 3: change one variable at a time.

Configuring the HID keyboard

CubeMX generates a descriptor for a mouse by default, even after selecting “HID Keyboard” in the middleware config. For the host to correctly recognize this as a keyboard, three things need fixing.

1. Interface Descriptor: redeclare these three fields with the right class:

bInterfaceClass    = 0x03  // HIDbInterfaceSubClass = 0x01  // Boot Interface SubclassbInterfaceProtocol = 0x01  // Keyboard (CubeMX defaults to 0x02 = Mouse)

2. HID Report Descriptor: CubeMX auto-generates this array’s content as HID_MOUSE_ReportDesc (74 bytes, HID_MOUSE_REPORT_DESC_SIZE = 74U), structured as 3 button bits followed by relative delta X/Y/Wheel values, with no concept of a “6-keycode slot” anywhere. That’s exactly why an 8-byte keyboard-style report sent into a mouse driver produces no characters at all. On the host, the HID driver parses the report against that structure and finds no matching field.

This needs replacing with an array describing the Boot Keyboard’s 8-byte report, HID_KEYBOARD_REPORT_DESC_SIZE = 45U:

static uint8_t HID_Keyboard_ReportDesc[HID_KEYBOARD_REPORT_DESC_SIZE] ={  /* The old mouse array (74 bytes): 0x05,0x01, 0x09,0x02 (Usage Mouse), ... X/Y/Wheel delta */  0x05, 0x01, 0x09, 0x06,  /* Usage Page (Generic Desktop), Usage (Keyboard) */  0xA1, 0x01,              /* Collection (Application) */  /* ... modifier byte, reserved byte, 6-byte keycode array ... */  0xC0                     /* End Collection */};

The full real 45-byte array, along with the diff against the original mouse version, is in the “Interface 0: HID Boot Keyboard” section of the Composite Device project page.

HID_KEYBOARD_REPORT_DESC_SIZE = 45U is the length of the HID_Keyboard_ReportDesc array declared above. Change the array’s content without updating this number, or vice versa, and wDescriptorLength in the HID Descriptor drifts out of sync with the real report descriptor, causing the host to read too few or too many bytes during enumeration.

3. The Setup handler: after reading the HID Descriptor, the host sends GET_DESCRIPTOR to fetch the Report Descriptor. The handler has to point to the exact array declared in step 2, not the old mouse report descriptor array. In this project, that code lives inside USBD_HID_Setup() in usbd_hid.c:

case USB_REQ_GET_DESCRIPTOR:  if ((req->wValue >> 8) == HID_REPORT_DESC)  {    len  = MIN(HID_KEYBOARD_REPORT_DESC_SIZE, req->wLength);    pbuf = HID_Keyboard_ReportDesc;  }  /* ... */

The later composite version from step 2 (usbd_composite.c, Composite_Setup()) reuses this exact same logic, just with different function and variable names.

Skip any one of these three steps and you get the same symptom: the descriptor “looks right” in the source but the host still misreads it. Fix only step 1 and forget steps 2 or 3, and bInterfaceProtocol correctly reports Keyboard, but the Report Descriptor actually returned is still the mouse one. The host picks the right driver name but parses the report structure wrong, so keypresses produce no characters at all, or produce meaningless cursor movement instead.

The 8-byte report format

Here’s the fixed format for keyboards per the HID spec:

typedef struct __attribute__((packed)) {  uint8_t modifier;     /* Bit mask: LCtrl=0, LShift=1, LAlt=2, LGui=3, ... */  uint8_t reserved;     /* Always 0x00 */  uint8_t keycode[6];   /* Up to 6 keys at once */} HID_KeyboardReport_t;

modifier is an 8-bit bitmask, with each bit tied to exactly one modifier key (Ctrl, Shift, Alt, GUI, on either the left or right side), independent of the 6 keycode slots that follow. Being a bitmask, several bits can be set at once. It’s never limited to a single bit.

The full 8-bit modifier table and how to read this bitmask is covered in What Is the HID Keyboard Report Format?.

A few examples:

  • Holding Left Shift while typing ‘a’ gives modifier = 0x02 (bit 1) and keycode[0] = 0x04 (the usage for ‘a’). The report is:
02 00 04 00 00 00 00 00
  • Holding Left Ctrl and Left Alt at the same time gives modifier = 0x01 | 0x04 = 0x05. The report is:
05 00 00 00 00 00 00 00
  • Holding the “1” key gives keycode[0] = 0x1E (the usage for ‘1/!’). The report is:
00 00 1E 00 00 00 00 00

Why the device doesn’t accept Output reports for LEDs

A standard 101-key keyboard layout usually has 3 LEDs: Num Lock, Caps Lock, Scroll Lock. These three keys are themselves ordinary keys, with their own usage, and show up in an Input report like any other key press. But the on/off state of those 3 LEDs is a separate Output report, traveling from host down to device, not an Input report going from device up to host. Both directions, IN and OUT, get declared inside the same HID Report Descriptor.

This Output report direction typically uses 1 byte, with 3 bits carrying LED state and 5 unused padding bits.

This project doesn’t implement that Output report direction, since none of the keypad’s 16 keys are set up as HID_USAGE_CAPS_LOCK, _NUM_LOCK, or _SCROLL_LOCK. As a result, the device never triggers any lock-key state, so there’s nothing to synchronize back. Wiring up an external LED for this also isn’t necessary, since it has nothing to do with USB itself.

On Boot Protocol, usbd_composite.c does genuinely handle the two requests HID class requires, SET_PROTOCOL and GET_PROTOCOL:

case HID_REQ_SET_PROTOCOL:  hcomp->hidProtocol = (uint32_t)(req->wValue);  break;case HID_REQ_GET_PROTOCOL:  (void)USBD_CtlSendData(pdev, (uint8_t *)&hcomp->hidProtocol, 1U);  break;

Firmware stores whatever protocol value the host requests and answers correctly when asked, but doesn’t branch its report-building logic based on that value. The reason was covered in post 3: this device’s Report Descriptor already describes the fixed 8-byte Boot Keyboard format, so the same report buffer works for both Boot Protocol and Report Protocol.

Three kinds of keys in key_table.c

Post 4 introduced the KeyTableEntry_t struct and its kind field, without going into detail. I use it to distinguish three different kinds of keys:

typedef enum {  KEY_KIND_NORMAL = 0,   // a single key: modifier + usage  KEY_KIND_MACRO,        // a macro, a multi-step sequence identified by macroId  KEY_KIND_SPECIAL       // reserved for layer switch, media keys, etc.} KeyKind_t;

The sKeyTable in key_table.c currently only uses 2 of these 3 kinds:

  // Kind          // Modifier   // Usage     // MacroId    // Repeat{ KEY_KIND_NORMAL, HID_MOD_NONE, HID_USAGE_1, MACRO_NONE,   1U },  /* keyLoc 0->11*//* ... */{ KEY_KIND_MACRO,  HID_MOD_NONE, 0x00U,       MACRO_CTRL_C, 0U },  /* keyLoc 12->15 *//* ... */

KEY_KIND_NORMAL applies to the first 12 keys (digits, letters, Enter/Space/Backspace/Tab). The report-building function reads the usage field directly and sends a single keycode.

KEY_KIND_MACRO applies to the last 4 keys. The usage field goes unused here. Instead, macroId points to a predefined report sequence, which gets sent step by step.

No entry in the current table uses KEY_KIND_SPECIAL. It’s a placeholder left in the enum, not finished logic. The original comment in the header spells out the intended direction: “layer switch, media keys, etc.” As an illustration of that direction (not yet implemented), a KEY_KIND_SPECIAL entry could represent a layer-switch key, where instead of building a normal report, it would swap out the active sKeyTable mapping, so all 16 physical keys take on a different meaning, or a different layout, all at once.

Debugging with ST-Link and CubeIDE

Debugging with ST-Link and CubeIDE: watching a keypress event turn into an HID report.

Handling a normal key

The section above answered “which usage does this key look up.” What’s still missing is two things: where that usage value actually comes from, and how it turns into an 8-byte report.

Where does the usage value come from?

Every key on a keyboard has exactly one fixed usage value, defined by the USB HID Usage Tables standard from USB-IF. These values are identical across every HID keyboard, regardless of manufacturer. The 12 ordinary keys used in this project follow that same standard:

KeyUsage (hex)
1 / 2 / 3 / 40x1E / 0x1F / 0x20 / 0x21
A / B / C / D0x04 / 0x05 / 0x06 / 0x07
Enter0x28
Space0x2C
Backspace0x2A
Tab0x2B

The full table of every usage value (a-z, digits, punctuation, numpad, lock keys, function keys, and more) is covered in What Are HID Usage Tables?.

The lookup table in key_table.c just picks out the 12 specific values this project needs from that standard.

How does a usage value turn into a report?

For KEY_KIND_NORMAL, entry->usage goes straight into keycode[0], and entry->modifier goes straight into the report buffer’s first byte.

Here’s a concrete example with keyLoc = 4 (the A key, per the layout chosen in post 4):

KeyTable_Get(keyLoc=4)
  → { KEY_KIND_NORMAL, modifier=HID_MOD_NONE (0x00), usage=HID_USAGE_A (0x04) }

      modifier = 0x00, keycode[0] = 0x04

report bytes: 00 00 04 00 00 00 00 00

This is where the two layers genuinely meet: the input layer (a physical key becoming an event) and the HID layer (a standard HID usage value). The bridge between them is exactly this key_table lookup.

Tap-style null reports

If firmware only ever sent a key-down report, the host would hold on to that key’s state. The OS applies its own typematic repeat at its own pace (typically a 500ms delay, then 30ms between repeats). That’s not wrong on its own, but imagine firmware crashing or hanging partway through. The host would assume the key is still held and keep spamming the same character onto the screen, with no way for the user to stop it.

To limit that risk, and to let firmware control repeat speed itself, one approach is sending a null report immediately after every key-down report.

With this approach, sending a single keypress splits into 2 steps:

  1. Send the key-down report (the press).
  2. Send a null report (the release).

Here’s the pseudocode for that logic:

START

    IF 1: a null report needs to be sent THEN
        Build a NULL REPORT

        IF 2: it was sent successfully THEN
            Clear the null-report-pending flag
        END IF 2

        RETURN

    END IF 1


    IF 3: a key is pressed or repeating THEN
        Build a report for that key

        IF 4: it was sent successfully THEN
            Set the null-report-pending flag
        END IF 4
    END IF 3

END

A note on the Transport layer

ST’s USBD_HID_SendReport() behaves ambiguously: when the endpoint is busy, the function can still return USBD_OK even though nothing was actually sent. You can’t fully trust the return value alone.

The fix is managing a busy flag yourself, with a rollback path when sending fails:

/* Trimmed down from usb_hid_keyboard.c, see the full version in the repo */static volatile bool sTxBusy = false;bool UsbHidKeyboard_SendReport(const HID_KeyboardReport_t *report){  if (sTxBusy) return false;  /* drop it, the caller retries later */  sTxBusy = true;  if (USBD_HID_SendReport(&hUsbDeviceFS, (uint8_t *)report,                           sizeof(HID_KeyboardReport_t)) != USBD_OK)  {    sTxBusy = false;  /* the rollback matters here */    return false;  }  return true;}/* Called from Composite_DataIn when EP1 IN completes */void UsbHidKeyboard_OnTxCplt(void){  sTxBusy = false;}

The rollback isn’t optional. If USBD_HID_SendReport fails and sTxBusy never gets reset to false, firmware stays stuck in a busy state forever and can’t send anything else, even though the hardware endpoint has gone idle.

Rolling back on a failed send

UsbHidKeyboard_SendReport returning false on busy is just one failed call. What actually matters is how the caller (HidKeyboardConvert_Run) handles that failure. A simple rollback mechanism is keeping the event in the queue when a send starts, and only popping it once the send has actually succeeded.

Combined with tap-style null reports, here’s how that’s implemented:

/* Trimmed down from hid_keyboard_convert.c */void HidKeyboardConvert_Run(void){  if (sNeedNullReport) {    /* ... build the null report ... */    if (UsbHidKeyboard_SendReport(...)) {      sNeedNullReport = false; /* clear the flag once sent successfully */    }    return;   /* if the send fails, keep the flag set and retry next time */  }  if (!KeyEventQueue_Peek(&event)) return;   /* peek, don't pop yet */  /* ... build a report from the event ... */  if (UsbHidKeyboard_SendReport(...))  {    (void)KeyEventQueue_Pop(NULL);   /* only pop once the send succeeds */    sNeedNullReport = true;  }  /* If the send fails: don't pop, the event stays in the queue,     and the next call to HidKeyboardConvert_Run will peek the same     event again and retry. */}

This principle applies consistently across every kind of report that needs sending:

Event type On a successful send On a failed send
Normal key-down, ErrorRollOver Pop from the queue, set the null-report flag Don't pop, keep the state as is, retry the exact same thing next call
Macro Advance the macro step to build the next report Don't pop, keep the state as is, retry the exact same thing next call
Null report Clear the null-report flag Keep the null-report flag set, retry sending the null report next call

On the other hand, an event that never produces a report at all (KEY_EVENT_OFF, a keyLoc missing from key_table, or KEY_KIND_SPECIAL, which isn’t implemented) always gets popped immediately, regardless of send state. There’s simply nothing to retry.

The result of this design: no matter how long the endpoint stays busy, no key-down, macro step, or null report ever gets dropped. Each one just waits for the next call to HidKeyboardConvert_Run, once the endpoint frees up, and tries again.

Handling macro keys

A handful of keys on the 4x4 keypad are mapped to multi-step macros: Ctrl+C, Ctrl+V, Ctrl+S, Alt+Tab. Each macro is a sequence of reports sent one after another. Fundamentally, a macro key is still just a key, except instead of sending a single report, it sends several reports in sequence, one per step. Each step can be either a key-down or a null report.

Here are 2 macros with their steps expressed in C:

typedef struct {  uint8_t modifier;  uint8_t keycode;} MacroStep_t;/* Macro: Ctrl+C */static const MacroStep_t kMacroCtrlC[] ={  { HID_MOD_LEFT_CTRL , HID_USAGE_C      },  /* Step 1: key-down */  { 0x00,               0x00             },  /* Step 2: null report */};/* Similarly for the macro "Hello" */static const MacroStep_t kMacroHello[] ={  { HID_MOD_LEFT_SHIFT, HID_USAGE_H      }, /* H */  { 0x00,               0x00             },  { 0x00,               HID_USAGE_E      }, /* e */  { 0x00,               0x00             },  { 0x00,               HID_USAGE_L      }, /* l */  { 0x00,               0x00             },  { 0x00,               HID_USAGE_L      }, /* l */  { 0x00,               0x00             },  { 0x00,               HID_USAGE_O      }, /* o */  { 0x00,               0x00             },};

The state machine in hid_keyboard_convert.c keeps two variables, sMacroId and sMacroStep:

  • sMacroId identifies which macro is currently running (Ctrl+C, Ctrl+V, and so on)
  • sMacroStep identifies the current step within that macro

sMacroStep resets to 0 the moment a macro key is pressed, and advances after each successful UsbHidKeyboard_SendReport call. If a send fails (the endpoint is busy), the current value is kept as is and retried on the next call.

Demo video

This is the first genuinely working milestone in the entire series. After plugging the device into a computer, the host correctly recognizes it as an HID keyboard and displays the right characters on keypress. The demo video below runs three frames side by side (Wireshark capturing USB packets, Notepad showing typed characters, and a camera on the actual keypad) so you can directly compare the physical press, the HID report sent, and the resulting character. It covers all 16 keys, holding a key to show repeat, all 4 macro combinations (Ctrl+C/V/S, Alt+Tab) running their full multi-step sequences correctly, and pressing 2 keys at once to trigger ErrorRollOver.

A real HID keyboard demo on the host

Byte-level detail from USBView and Wireshark is available on the Composite Device project page.

Summary

Post 5 finishes the second half of the keyboard pipeline: from an event sitting in the queue to an 8-byte report actually landing on the host. The core focus here is making sure no event ever gets dropped, even while the USB endpoint is busy. Every report only gets popped from the queue after a successful send, and tap-style null reports, along with every step of a macro, follow that same rollback principle.

This post also covers the three things that have to be right for the host to correctly recognize an HID Keyboard: fixing the Interface Descriptor, replacing the Report Descriptor with the Boot Keyboard’s 45-byte version, and returning the correct descriptor array inside the GET_DESCRIPTOR handler. Only once all three line up does the host parse the report correctly and display the right character.

At the convert layer, this post covers how a usage value gets looked up from the standard HID table, and how modifier and keycode combine into an 8-byte report. At the transport layer, it shows why ST’s USBD_HID_SendReport() can’t be trusted on its own, and why firmware needs to manage its own busy flag along with a rollback on failed sends.

Finally, this post implements a state machine for multi-step macros (Ctrl+C/V/S, Alt+Tab), handling every report correctly without skipping a step.

This is also the first milestone in the series where the HID keyboard runs end to end: the host recognizes it correctly, reports get sent correctly, and characters display correctly. The next post adds a CDC log channel, so firmware can be observed without a debugger attached to the board.

References

Found this article useful?

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

Feedback