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.
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:
KeyEvent_t
Receives a key event: ON / OFF / REPEAT / ERROR.
Build report
Takes the event, looks it up in key_table, builds an 8-byte report.
Send report
Checks busy state before sending.
Interrupt IN
The 8-byte report gets sent to the host each time.
OS / BIOS
Reads the report according to the structure declared in the descriptor.
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.centirely 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) andkeycode[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:
| Key | Usage (hex) |
|---|---|
| 1 / 2 / 3 / 4 | 0x1E / 0x1F / 0x20 / 0x21 |
| A / B / C / D | 0x04 / 0x05 / 0x06 / 0x07 |
| Enter | 0x28 |
| Space | 0x2C |
| Backspace | 0x2A |
| Tab | 0x2B |
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:
- Send the key-down report (the press).
- 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:
sMacroIdidentifies which macro is currently running (Ctrl+C, Ctrl+V, and so on)sMacroStepidentifies 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
- USB HID Usage Tables 1.5, keycode mapping, usage pages
- Device Class Definition for HID 1.11, Boot Keyboard report format
Found this article useful?
Share, give feedback, or support if you find this content valuable.