STM32 USB HID Keyboard Tutorial: Build a Custom USB Keyboard with the Blue Pill (2026)

Build an STM32F103 Blue Pill USB HID keyboard with CubeMX, HAL, 63-byte report descriptor, eight-byte reports, PB0 button, debounce and fixes.

Quick answer: An STM32F103C8PriceAliExpress price Blue PillPriceAliExpress price can behave as a USB HID keyboard without a USB-to-UART adapter or a custom desktop driver. Its native full-speed USB peripheral uses PA11 (D−) and PA12 (D+). Configure a reliable 48 MHz USB clock, generate a USB Device HID project in STM32CubeMX, then modify the stock mouse-oriented HID class into a keyboard by replacing the interface and report descriptors and increasing the interrupt-IN endpoint to eight bytes. In the example below, a push-button on PB0 types a single a into a focused text editor, with a separate release report when you let go. A fresh Blue PillAliExpress price still needs an ST-LINKPrice over SWD or another supported non-USB programming method for its first firmware upload.

This esp32.co.uk/ STM32 guide shows more than a magic byte array. We explain how a USB host discovers a HID keyboard, which changes belong in the middleware, why an eight-byte key report must be paired with a proper descriptor, how to handle the stock ST transmission API’s misleading return value, and how to verify USB enumeration on Windows, Linux, and macOS. We also address button bounce, host LED requests, keyboard layouts, power and bootloader pitfalls. The target is an STM32F103C8T6 Blue Pill using the classic STM32CubeF1 USB device middleware and HAL. Generated files and API details vary by package version. The proposed code is documentation-reviewed, not physically bench-tested.

A Blue Pill can present itself as a USB HID keyboard once the firmware and descriptors are configured.

USB HID keyboard versus USB serial

A USB CDC device looks like a serial port. The USB HID class instead exposes standardized input and output reports so operating systems can recognize keyboards, mice, game controllers and other controls. With a compatible report descriptor, a keyboard usually uses the operating system’s built-in HID stack rather than installing a bespoke application driver. In contrast with the #041 USB CDC tutorial, there is no COM port to open, and the device does not send printable ASCII characters directly.

A keyboard report sends usage IDs such as 0x04 for the key labeled A on a US keyboard. The operating system interprets those IDs according to its current keyboard layout, modifier keys and focus. A user with a Greek keyboard layout may see a different character than a user with an English (US) layout. The firmware is describing a physical-key event, not encoding Unicode text. That distinction is essential when turning the tutorial into macros or keypad products.

The project sends a key only after a real, debounced button press. Deliberately do not have a USB keyboard immediately type on connection: it could send input to a terminal, browser address bar or password field. Test in an empty text editor, not inside a command prompt or banking application. USB HID is a standard interface, but pretending to be a keyboard gives an MCU control over whichever host window is focused. Treat that capability seriously.

TopicUSB HID keyboardUSB CDC virtual COM port
Host presentationKeyboard input deviceSerial/COM port
Host applicationFocused GUI, terminal or OS input subsystemSerial terminal or serial API
Data formatHID reports with usage IDsByte stream
Device stackHID class, endpoint and report descriptorCDC ACM class and endpoints
Additional PC driverUsually standard HID keyboard driverUsually standard CDC driver
Typical projectMacro pad, accessibility controller, control panelDebug console, sensor data link
RiskAccidentally typing into the wrong appWrong serial protocol or buffering

The F103’s integrated USB interface is full-speed device only. The Blue Pill is not a USB host that can read an external keyboard through its USB socket, and the factory F103C8 system-memory bootloader is not a USB DFU bootloader. HID firmware must already be running for the board to enumerate as a keyboard.

Hardware and safe wiring

Use a Blue Pill whose chip and USB circuitry are understood, a data-capable Micro-USB cable, an ST-LINK with known SWD wiring, a normally-open push-button, and two short jumpers. An optional 100 nF decoupling capacitor across the supply rails can be useful on a breadboard, but the USB port’s existing power decoupling must remain in place. The button is not connected to USB D+ or D−.

ConnectionSTM32F103C8 signalWhat it does
USB Micro-B D−PA11 / USB_DMUSB full-speed data minus
USB Micro-B D+PA12 / USB_DPUSB full-speed data plus
USB connector 5 VOnboard regulator input, board-dependentPowers board when designed for USB supply
USB GNDMCU groundCommon return
Button first legPB0Active-low input with MCU internal pull-up
Button second legGNDPulls PB0 low when pressed
ST-LINK SWDIOPA13Debug/program data
ST-LINK SWCLKPA14Debug/program clock
ST-LINK GNDGNDCommon debug reference

Configure PB0 as GPIO_Input with Pull-Up in CubeMX. In that mode the unpressed switch reads HIGH and pressing it connects the pin to GND, producing LOW. Do not connect PB0 directly to 5 V. The board and the PC already share ground through USB; the same MCU cannot have two separate ground nets, so a single short button connection from PB0 to board GND is sufficient. The diagram in this article deliberately uses a logical connection rather than guessed physical pin-header positions on clone PCBs.

The classic F103 USB peripheral needs a full-speed approximately 1.5 kΩ D+ pull-up to 3.3 V. Some inexpensive Blue Pill variants have a noncompliant resistor value, often called R10, although the reference designator varies. A cable that supplies power but no data will also light the board’s LED without enumeration. Check the exact PCB revision, cable and D+ hardware if Windows or Linux never notices a new USB device. Do not wire 5 V directly to the MCU’s 3V3 pin, PA11 or PA12. When using ST-LINK alongside USB, connect target GND and VTref appropriately; avoid feeding independent conflicting power sources into the target.

STM32F103C8 PB0 normally open button to GND USB PA11 D minus PA12 D plus and ST-LINK SWD wiring
The button is on PB0 with internal pull-up; USB data uses PA11 D− and PA12 D+.

How a PC recognizes a keyboard

After the device pulls D+ high and exchanges USB packets, the host reads a series of descriptors. The device descriptor identifies USB version, product IDs and basic capabilities. The configuration descriptor describes an interface and its endpoint. The HID descriptor points to a separate HID report descriptor. Finally, the host polls the interrupt-IN endpoint for actual key reports. All those pieces must agree with each other.

Descriptor or endpointEssential fieldKeyboard setting in this tutorial
InterfacebInterfaceClass0x03 = HID
InterfacebInterfaceSubClass0x01 = Boot Interface
InterfacebInterfaceProtocol0x01 = Keyboard (0x02 means mouse)
HID descriptorReport descriptor length63 bytes for the complete example below
Interrupt IN endpointbEndpointAddressTypically 0x81 (IN endpoint 1)
Interrupt IN endpointwMaxPacketSize8 bytes, not stock mouse’s 4
Polling intervalbIntervale.g. 10 ms for full-speed (configurable)
Keyboard inputStandard boot report8 bytes, with modifiers and six key slots

The interface’s boot subclass and keyboard protocol help legacy boot environments understand the device, but genuine pre-boot compatibility also depends on correct keyboard reports, HID class request handling and host firmware. A correct interface byte alone is not proof that a BIOS/UEFI setup will accept the device. Modern OS recognition, not boot-menu control, is the initial acceptance test.

ST’s classic HID middleware normally starts out as a four-byte mouse. Its default usbd_hid.h defines HID_EPIN_SIZE as 0x04, and its usbd_hid.c normally sets the interface protocol to 0x02 and supplies a mouse report descriptor. Leaving any of these mouse settings in place while transmitting eight keyboard bytes is a common failure. Both the actual endpoint opening size and the configuration-descriptor advertised packet size must change together. ST’s reference middleware source and a technical moderator’s mouse-to-keyboard migration guide confirm that pattern.

Understand the eight-byte keyboard report

A conventional boot-style keyboard input report contains one modifier byte, a reserved byte, then up to six usage IDs. The modifier is a bitmask rather than an ordinary keycode. For example, left Shift is 0x02, left Control is 0x01, and right GUI is 0x80. The six key slots use HID keyboard page usage IDs, not ASCII.

Byte indexPurposeExample for pressing A
0Modifier bits0x00
1Reserved byte0x00
2First normal key0x04 (A key usage)
3Second key0x00
4Third key0x00
5Fourth key0x00
6Fifth key0x00
7Sixth key0x00

To press the A key, send 00 00 04 00 00 00 00 00. To release every key, send 00 00 00 00 00 00 00 00. Both reports matter. Merely sending an A report repeatedly does not reliably produce one A per call. The host interprets an unchanged state as the key remaining down, potentially invoking its own repeat mechanism after a delay.

An uppercase A needs a modifier plus key: 02 00 04 00 00 00 00 00 for left Shift plus A. This is not equivalent to ASCII 0x41, and it is not a way to generate arbitrary Unicode. The current keyboard layout may change the character that appears in the editor. In particular, the A key usage is a keyboard position standardized on the HID usage page.

HID boot keyboard eight-byte input report showing modifier reserved six keycodes press A and release zeros
Eight-byte keyboard reports describe held keys, not ASCII characters.

Prepare the STM32CubeMX project

Select the actual STM32F103C8Tx part in STM32CubeMX and enable Serial Wire debugging under SYS. Configure RCC for the crystal oscillator fitted to your board. The common Blue Pill’s 8 MHz HSE ×9 yields 72 MHz PLLCLK, then the USB prescaler ÷1.5 yields the precise 48 MHz USB clock required for USB enumeration. The APB1 prescaler should be ÷2 to keep PCLK1 at 36 MHz; APB2 can remain ÷1. This is a clock example for verified 8 MHz HSE hardware, not a universal recipe for every F103 clone.

Under Connectivity → USB, enable USB device mode, then in Middleware → USB_DEVICE choose Human Interface Device Class (HID) if the installed STM32CubeF1 package exposes it. Some software distributions label it USB Device HID, and newer STM32CubeIDE 2.x workflows may have the separate CubeMX generate an IDE project. Verify that USB_DEVICE/App/usb_device.c and the class files really appear after generation. Enable the USB interrupt and ensure the low-priority USB IRQ links to the HAL peripheral controller. The USB stack handles most of that when correctly generated.

Set PB0 = GPIO_Input + Pull-Up. Name it KEY_BTN if your CubeMX version makes labels convenient, but the examples use GPIOB and GPIO_PIN_0 directly so they don’t depend on the symbolic label. Avoid EXTI for the first test: a small foreground debounce state machine is simpler, and no button ISR is required. Save the .ioc, generate the files, import into CubeIDE and build once before changing HID middleware, to establish that the base USB application compiles.

CubeMX settingExample selectionWhy
MCUSTM32F103C8TxMatch Blue Pill silicon
RCCHSE crystal (verified 8 MHz)Accurate USB reference
ClockPLL ×9, USB ÷1.572 MHz system, 48 MHz USB
USBUSB Device Full SpeedNative PA11/PA12 interface
USB_DEVICE middlewareHIDProvides HID class and setup requests
SYSSerial WirePreserve SWD recovery
PB0GPIO input pull-upButton to GND, active low
NVICUSB device interrupt enabledEnumerate and transfer reports

Keep the standard HID class changes in version control. CubeMX code regeneration can overwrite modified middleware files depending on package layout and project settings. If regenerated output reverts to a mouse, your button code might be perfectly correct while the host still sees a mouse. Preserve a reproducible patch rather than trusting the USER CODE markers to protect third-party library internals.

Convert the stock HID mouse into a keyboard

Make a local copy of the generated project’s USB library files and identify the exact version. Typically they live under Middlewares/ST/STM32_USB_Device_Library/Class/HID/Inc/usbd_hid.h and .../Src/usbd_hid.c. Some CubeMX projects reference an external package folder instead. The following changes assume the classic single-class HID device, not a composite HID+CDC device, and a conventional 8-byte keyboard with no Report ID. Composite devices require separate interface numbers, report IDs where needed, and different endpoint handling.

In usbd_hid.h, replace the mouse endpoint size and report length with these keyboard defaults. Retain any #ifndef wrapper that lets the project override an endpoint definition, and use the same macros consistently in your source. The default HID configuration descriptor remains 34 bytes: its topology still contains one interface, one HID descriptor and one IN endpoint.

/* Class/HID/Inc/usbd_hid.h -- single HID keyboard configuration */
#define HID_EPIN_ADDR                  0x81U
#define HID_EPIN_SIZE                  0x08U
#define USB_HID_CONFIG_DESC_SIZ       34U
#define USB_HID_DESC_SIZ              9U
#define HID_KEYBOARD_REPORT_DESC_SIZE 63U

If those macros are already defined by the library, edit their existing definitions instead of appending duplicates. Remove the obsolete mouse-length definition once you have replaced every source reference. The HID report descriptor byte count must agree with the array length and with both USB descriptor responses. Merely changing the wMaxPacketSize byte displayed to the host is not enough if USBD_LL_OpenEP() still uses a 4-byte buffer.

In usbd_hid.c, edit the interface portion of USBD_HID_CfgDesc: set its existing mouse bInterfaceProtocol value from 0x02 to 0x01, retaining bInterfaceClass = 0x03 and bInterfaceSubClass = 0x01. Change the HID report descriptor length entry to HID_KEYBOARD_REPORT_DESC_SIZE. Change the endpoint’s existing HID_EPIN_SIZE to the new 8-byte header macro. Also change the standalone HID descriptor array USBD_HID_Desc to report the new 63-byte length; the source normally contains two separate places containing the old mouse report length.

/* Values at their EXISTING positions inside USBD_HID_CfgDesc */
0x03, /* bInterfaceClass: HID */
0x01, /* bInterfaceSubClass: Boot */
0x01, /* bInterfaceProtocol: Keyboard (was Mouse 0x02) */
/* ... inside its HID descriptor ... */
0x22, /* Report descriptor type */
HID_KEYBOARD_REPORT_DESC_SIZE, /* low byte = 63 */
0x00, /* high byte */
/* ... inside its IN endpoint descriptor ... */
HID_EPIN_ADDR,
0x03, /* interrupt IN */
HID_EPIN_SIZE, 0x00, /* 8-byte max packet, little endian */
HID_FS_BINTERVAL

The snippets above are replacement fragments, not a second configuration descriptor to paste into the same C file. Keep the original surrounding configuration fields and the existing bracket structure. In USBD_HID_Desc replace the old report-length macro in exactly the same way. Then rename the original mouse array, for example HID_MOUSE_ReportDesc, to HID_Keyboard_ReportDesc and replace its contents with the 63-byte keyboard report descriptor below.

/* Class/HID/Src/usbd_hid.c -- 63-byte HID keyboard report descriptor */
__ALIGN_BEGIN static uint8_t HID_Keyboard_ReportDesc[HID_KEYBOARD_REPORT_DESC_SIZE]
__ALIGN_END = {
  0x05, 0x01,       /* Usage Page: Generic Desktop */
  0x09, 0x06,       /* Usage: Keyboard */
  0xA1, 0x01,       /* Collection: Application */
  0x05, 0x07,       /* Usage Page: Keyboard/Keypad */
  0x19, 0xE0,       /* Usage Min: Left Control */
  0x29, 0xE7,       /* Usage Max: Right GUI */
  0x15, 0x00,       /* Logical Min: 0 */
  0x25, 0x01,       /* Logical Max: 1 */
  0x75, 0x01,       /* Report Size: 1 bit */
  0x95, 0x08,       /* Report Count: 8 */
  0x81, 0x02,       /* Input: 8 modifier bits */
  0x95, 0x01,       /* Report Count: 1 */
  0x75, 0x08,       /* Report Size: 8 bits */
  0x81, 0x03,       /* Input: reserved byte */
  0x95, 0x05,       /* Report Count: 5 */
  0x75, 0x01,       /* Report Size: 1 bit */
  0x05, 0x08,       /* Usage Page: LEDs */
  0x19, 0x01,       /* Usage Min: Num Lock */
  0x29, 0x05,       /* Usage Max: Kana */
  0x91, 0x02,       /* Output: 5 LED bits */
  0x95, 0x01,       /* Report Count: 1 */
  0x75, 0x03,       /* Report Size: 3 bits */
  0x91, 0x03,       /* Output: padding */
  0x95, 0x06,       /* Report Count: 6 */
  0x75, 0x08,       /* Report Size: 8 bits */
  0x15, 0x00,       /* Logical Min: 0 */
  0x25, 0x65,       /* Logical Max: 101 */
  0x05, 0x07,       /* Usage Page: Keyboard/Keypad */
  0x19, 0x00,       /* Usage Min: Reserved */
  0x29, 0x65,       /* Usage Max: Keyboard Application */
  0x81, 0x00,       /* Input: six keycode bytes */
  0xC0              /* End collection */
};

These descriptors declare an output LED report, as conventional keyboards do. For the original stock HID implementation, merely declaring it does not mean the middleware processes SET_REPORT automatically. We’ll handle a one-byte LED output on the control endpoint below; a project that omits this feature might still type on many hosts but would not be a complete, reliable boot-style keyboard implementation.

Inside USBD_HID_Setup(), find the USB_REQ_GET_DESCRIPTOR class handler and make it return the keyboard array and keyboard length, not the mouse array and 74-byte constant. All locations need to agree. The code pattern should be:

if ((req->wValue >> 8) == HID_REPORT_DESC) {
    len = MIN(HID_KEYBOARD_REPORT_DESC_SIZE, req->wLength);
    pbuf = HID_Keyboard_ReportDesc;
}

If you use different library versions, confirm the USBD_HID_GetFSCfgDesc(), USBD_HID_GetHSCfgDesc() and other-speed function paths all return the edited configuration descriptor. The F103 has only full-speed device hardware, but the middleware may expose generic high-speed callbacks across MCU families. Do not accidentally edit only a high-speed descriptor while the board enumerates using a separate full-speed array.

Handle host keyboard LED reports

The report descriptor above advertises a one-byte Output report for indicators such as Num Lock and Caps Lock. Many operating systems transmit that value with a class SET_REPORT control request on endpoint zero. The stock classic HID implementation handles SET_IDLE and SET_PROTOCOL, but its mouse-oriented version does not generally have an application callback for keyboard LED output. A robust keyboard should accept the one-byte Output report even when your Blue Pill has no physical indicator LEDs attached.

Add the following file-scope state and endpoint-zero receive-ready function in usbd_hid.c. Register USBD_HID_EP0_RxReady in the class table entry that is currently NULL /* EP0_RxReady */. Make sure the function prototype appears before the class table. This is a targeted extension for the classic non-composite HID stack. The USB core invokes the callback after the control OUT data stage completes.

/* In usbd_hid.c, at file scope; declare before USBD_HID class table */
static uint8_t keyboard_led_report = 0U;
static uint8_t keyboard_led_rx_pending = 0U;

static uint8_t USBD_HID_EP0_RxReady(USBD_HandleTypeDef *pdev)
{
    UNUSED(pdev);
    keyboard_led_rx_pending = 0U;
    return (uint8_t)USBD_OK;
}

/* In the USBD_ClassTypeDef USBD_HID initializer:
   replace the existing NULL entry for EP0_RxReady with
   USBD_HID_EP0_RxReady (do not insert an additional field). */

Inside the USB_REQ_TYPE_CLASS / switch (req->bRequest) branch of USBD_HID_Setup(), add these cases, next to the stock GET/SET protocol and idle handling. The report type lives in the high byte of wValue: 0x02 means Output. The low byte is report ID; this example defines no report IDs, so require zero. A different HID composite or multi-report implementation needs a different decoder.

case USBD_HID_REQ_SET_REPORT:
    if ((req->wValue == 0x0200U) && (req->wLength == 1U)) {
        keyboard_led_rx_pending = 1U;
        (void)USBD_CtlPrepareRx(pdev, &keyboard_led_report, 1U);
    } else {
        USBD_CtlError(pdev, req);
        ret = USBD_FAIL;
    }
    break;

case USBD_HID_REQ_GET_REPORT:
    if ((req->wValue == 0x0200U) && (req->wLength >= 1U)) {
        (void)USBD_CtlSendData(pdev, &keyboard_led_report, 1U);
    } else {
        USBD_CtlError(pdev, req);
        ret = USBD_FAIL;
    }
    break;

The host can now set the indicator state and the firmware stores it, even though we don’t drive physical LEDs from the value. The keyboard_led_rx_pending flag is useful if you later choose to process the newly received byte in a deferred task. The stock class’s GET/SET protocol support can remain in place; because the keyboard sends the same eight-byte basic format in report and boot protocol, this simple demonstration does not need report IDs or a protocol-dependent payload switch.

One subtle USB detail: on a bus-powered device, remote wakeup and suspend handling are separate work. Do not toggle USB device state variables manually inside a button ISR to wake a sleeping host. This tutorial leaves remote wakeup disabled and treats the PB0 switch only as a local input when the host has configured the keyboard. If you need keyboard wake from suspend, implement the USB-defined remote-wakeup handshake and check whether the host enabled the feature first.

Fix the HID transmit-busy return value

Here is an unpleasant corner of ST’s classic HID API: in some versions, USBD_HID_SendReport() checks whether the HID class is idle and starts USBD_LL_Transmit() only in that case, but still returns USBD_OK even if the class was busy and the new report never started. Copying an example that treats every USBD_OK as an accepted key press can silently lose release reports, leaving a key logically held down.

Inspect your local usbd_hid.c implementation rather than assuming all firmware packages behave identically. If it has this behavior, change the existing USBD_HID_SendReport() function to return USBD_BUSY when a prior interrupt-IN packet is pending, and USBD_FAIL when not configured. Its device/class access and optional composite-class arguments are version-dependent, so retain the existing function signature, local handle lookup and endpoint address, changing only the decision/return block as appropriate:

/* Replace the state and send/return portion in the existing function.
   Keep its existing local hhid, pdev, HIDInEpAdd and arguments. */
if (pdev->dev_state != USBD_STATE_CONFIGURED) {
    return (uint8_t)USBD_FAIL;
}
if (hhid->state != USBD_HID_IDLE) {
    return (uint8_t)USBD_BUSY;
}
hhid->state = USBD_HID_BUSY;
if (USBD_LL_Transmit(pdev, HIDInEpAdd, report, len) != USBD_OK) {
    hhid->state = USBD_HID_IDLE;
    return (uint8_t)USBD_FAIL;
}
return (uint8_t)USBD_OK;

This change protects the press/release state machine that follows. It also makes debugging more meaningful: USBD_BUSY says try later rather than claiming the report was transmitted. The stock HID DataIn() callback sets the class state back to idle when the host has completed the IN transfer. If you later switch to a composite device or another ST stack generation, recheck its busy semantics and packet-buffer ownership; do not paste this fragment into a different function simply because the name looks similar.

Complete PB0 button to USB keyboard example

The following main.c code works with the generated F1 HAL project after the HID descriptor and API changes above. It uses foreground polling and a 25 ms debounce, sends one press report while PB0 is held LOW, then sends a release report when PB0 returns HIGH. It does not use EXTI and does not need a blocking delay to release a key. It also avoids writing to an in-flight USB packet buffer: the two eight-byte reports have static storage and are never modified.

Insert the declarations in /* USER CODE BEGIN 0 */ or other designated user-code sections, add the headers in the appropriate include section and call Keyboard_Task() from the application’s main while(1) loop, after the generated MX_USB_DEVICE_Init() call. Do not duplicate the generated main() or manually call the USB initialization twice.

/* main.c, inside an appropriate USER CODE section */
#include "usb_device.h"
#include "usbd_hid.h"
#include "usbd_def.h"

extern USBD_HandleTypeDef hUsbDeviceFS;

/* Press A, and release all keys. Both arrays remain unchanged forever. */
static uint8_t kbd_press_a[8] = {0x00, 0x00, 0x04, 0, 0, 0, 0, 0};
static uint8_t kbd_release[8] = {0, 0, 0, 0, 0, 0, 0, 0};

static void Keyboard_Task(void)
{
    static GPIO_PinState last_raw = GPIO_PIN_SET;
    static GPIO_PinState stable = GPIO_PIN_SET;
    static uint32_t change_ms = 0U;
    static uint8_t host_key_down = 0U;

    GPIO_PinState raw = HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_0);
    uint32_t now = HAL_GetTick();

    if (raw != last_raw) {
        last_raw = raw;
        change_ms = now;
    }
    if ((now - change_ms) >= 25U) {
        stable = raw;
    }

    if (hUsbDeviceFS.dev_state != USBD_STATE_CONFIGURED) {
        /* Re-enumeration should begin from a released-key state. */
        host_key_down = 0U;
        return;
    }

    uint8_t desired_down = (stable == GPIO_PIN_RESET) ? 1U : 0U;
    if (desired_down != host_key_down) {
        uint8_t *report = desired_down ? kbd_press_a : kbd_release;
        uint8_t result = USBD_HID_SendReport(&hUsbDeviceFS, report, 8U);
        if (result == USBD_OK) {
            /* Accepted into USB IN transfer; retry later on BUSY. */
            host_key_down = desired_down;
        }
    }
}

/* In main's generated while(1), after MX_USB_DEVICE_Init():
while (1) {
    Keyboard_Task();
    // Other nonblocking application work
}
*/

There are two deliberate simplifications. First, this is a hold-to-press design, not a one-shot macro: keeping the physical button held keeps A held on the host. The OS may generate repeated A characters after its normal keyboard repeat delay. For one character per push regardless of how long the button is held, you can schedule a separate release after successful transmission and let a new physical press re-arm it. Second, because the host could disconnect immediately after a press or release, the code does not promise delivery across unplug/replug events. The host has its own USB device state and handles disconnects independently.

The debounce logic ignores rapid transitions until raw input has stayed steady for at least 25 ms. It is a simple time-based filter appropriate for mechanical switch bounce. It does not sample on an interrupt, and it does not guarantee unusually noisy industrial switches will behave perfectly. For heavy electrical interference, use external filtering, EMI-aware wiring and a stronger input state machine. The elapsed-time arithmetic uses unsigned uint32_t subtraction so the HAL_GetTick() wrap does not break a short debounce period.

The two immutable static packet arrays matter because USB IN is asynchronous: the middleware may keep a pointer to the payload until transfer completion. Creating a local eight-byte array on the stack, passing it to USBD_HID_SendReport() and returning immediately risks the contents being overwritten. Similarly, editing the same static array before the previous transfer completes can corrupt what the host receives. With separate fixed press/release buffers and reliable busy feedback, this basic design avoids both mistakes.

STM32 PB0 button debounce 25 milliseconds send HID key A report retry USBD_BUSY release report
A debounced physical button drives two immutable reports; the application retries while USB is busy.

Flash and verify without typing into the wrong application

Start with the Blue Pill connected to the ST-LINK over SWD and the PB0 button connected to GND through its normally-open contacts. Keep the USB data cable disconnected during initial flash. Use CubeIDE’s debugger or STM32CubeProgrammer to flash the firmware; set BOOT0 to the appropriate normal flash boot level for the board. After a successful program and reset, connect the target’s Micro-USB port to a known-good PC USB data port. The host should register a new HID keyboard device. Some operating systems combine several keyboards under one human-readable label, so a generic USB Input Device name is not by itself a failure.

Open an empty text editor, click inside it, then press and release PB0. On an English (US) keyboard mapping, the expected outcome is a lower-case a while the device is configured. If it repeats while held, that’s host key-repeat behavior. If nothing appears, do not assume the USB clock is wrong until you check whether a HID device actually enumerated; descriptor failures and application report failures are different layers.

On Windows, check Device Manager under Keyboards and Human Interface Devices, and use USB Device Tree Viewer or a descriptor inspection tool when you need to see interface protocol, endpoint packet size and report length. You should not look for COMx; this is not the CDC device from #041. Windows may retain cached device information across firmware changes; disconnect/reconnect, use a changed product ID only when legally appropriate, or remove the old device instance while debugging.

On Linux, inspect dmesg -w, lsusb, and lsusb -v for the newly attached HID interface. Look for keyboard-specific HID descriptors and a bound usbhid device; udev and permissions for /dev/hidraw* are relevant only if you need raw HID access, not ordinary typing. For deeper descriptor debugging, inspect /sys/bus/hid/devices/.../report_descriptor and decode it with hid-tools. Be careful not to confuse a device present in the USB tree with a device whose report descriptor is actually accepted by the HID input subsystem.

On macOS, use System Information → USB to see the device and test a text editor such as TextEdit. macOS may present a keyboard setup assistant for a newly identified keyboard; this does not mean the USB hardware failed. The HID usage layout is still interpreted by the chosen input source. Keyboard shortcuts and accessibility permission prompts may differ from normal simple key entry; start with letter A rather than system shortcuts.

ObservationLikely layerFirst useful check
Power LED on, no USB deviceCable, D+ pull-up, 48 MHz clock, PA11/PA12Known data cable and measured clock plan
Device appears as a mouseStock HID descriptors still activeProtocol 0x02, old report array, regenerated class files
Unknown USB device / descriptor errorDescriptor structure or USB PHYReport length, wTotalLength, correct D+ resistor
HID device recognized but no typingApplication state or inputPB0 pull-up, host configured, debugger watch
One key press sticksRelease report droppedBusy handling, separate static report arrays
Characters appear wrongOS keyboard layoutCompare HID key usage to active language layout
Caps Lock LED behavior strangeOutput report handlingSET_REPORT and 1-byte LED state
Works until regenerate codeClass edits overwrittenVersion-controlled patch for middleware

Common failure: everything builds but the computer sees a mouse

This is the most characteristic failure of the STM32 classic HID project. The compiler cannot tell whether a report descriptor describes a keyboard or a mouse; both are just byte arrays. If the generated class code retains bInterfaceProtocol = 0x02, the host can treat the device as a mouse even though the MCU tries to send keyboard usage IDs. Look at the actual descriptors the USB host reads, not only your C source, because project generation and middleware copying can restore older arrays unexpectedly.

Check the length advertised in both USBD_HID_CfgDesc and USBD_HID_Desc, plus the actual report descriptor array used in the GET_DESCRIPTOR response. Confirm the endpoint IN packet size is eight in the advertised endpoint descriptor and the EP open call. For an ordinary single keyboard with no Report IDs, the report payload is exactly eight bytes. Do not append a report ID byte unless you have declared Report IDs in the report descriptor. For a combined keyboard + mouse report descriptor, the payload format will be different; that is a more advanced project.

A second descriptor trap is the device’s VID/PID. Do not distribute production firmware using someone else’s product ID or claim ST’s assigned VID/PID is yours. For a learning experiment, identify the IDs assigned by the example project and use the sample only according to the license and purpose allowed by its supplier. If you produce commercial hardware, obtain legitimate USB identifiers or an authorized PID allocation. A personalized product string is not a substitute for owning identifiers.

Common failure: key presses work but release disappears

A key release is a separate USB IN report and must be accepted by the HID class. The default ST HID USBD_HID_SendReport() implementation may return USBD_OK even when it does not submit a new report because the previous transfer is busy. If your state machine marks release as delivered on that misleading return, it will stop retrying, causing a stuck key until the next event or disconnect. The fix is to make the send API report USBD_BUSY reliably, and retry when the state clears.

Avoid huge blocking delays to paper over this race. The polling interval is not a guarantee that any particular call happens after the previous packet finished. Host scheduling, device suspend and concurrent callbacks can change timing. Correct software observes completion or reliably checks class state; it does not assume HAL_Delay(10) is always sufficient. A future keyboard project with multiple controls should queue desired input state rather than treating every tap as an independent fire-and-forget USB message.

Do you need USB HID Boot Protocol?

The terms HID report protocol and boot protocol are related but not interchangeable. In report protocol, the host parses the full HID report descriptor. A boot-capable keyboard interface exposes a standardized eight-byte input report usable by simple host firmware that may not parse all HID descriptors. A strict boot keyboard also needs appropriate class requests and LED output handling. The descriptor supplied here uses the conventional boot keyboard report format, identifies the interface as a boot keyboard, retains ST’s SET/GET protocol and idle handling, and includes the LED control-report extension above.

Even then, BIOS/UEFI support varies by host USB stack, hub, security policy and pre-boot environment. A Blue Pill appearing as a keyboard in Windows does not prove it will work inside firmware setup screens. Test that use case separately rather than advertising unconditional boot compatibility. If the product only targets desktop OS applications, a non-boot HID interface may be a reasonable alternative, but it still needs a consistent descriptor and payload format.

From one switch to a practical custom keypad

Once the single-key example works, add more switches with GPIO input pull-ups, each mapped to a distinct HID keyboard usage ID. Use a debounced state per button and assemble one eight-byte report representing all currently held keys. For two buttons, fill key slots 2 and 3 in the report with their usage IDs. For a matrix keypad, scan rows and columns in firmware and consider ghosting; diodes may be required for some multi-key combinations. Input report construction belongs in the application layer, while the USB HID class should remain an ordinary transport for complete reports.

The six-key rollover format supports up to six simultaneous ordinary keys plus modifier bits. That is a limitation of this standard eight-byte boot report, not of the entire HID protocol. NKRO keyboards can define larger bitfield reports, but a host that expects an eight-byte boot keyboard might not accept that layout in boot mode. More advanced report descriptors and protocol switching increase implementation and testing effort. For a handful of buttons, keeping the standard eight-byte format is often the best engineering choice.

For a macro pad, be thoughtful about shortcuts. Sending Control+S or a media key might be useful in one application and harmful in another. Implement a physical enable switch, a visible arming indicator and an explicit mapping configuration rather than sending a potentially destructive key sequence at power-up. If the keypad controls production machinery or accessibility equipment, consider host acknowledgment through a separate protocol: an HID keyboard report alone does not confirm that the intended application received or acted on the key.

Why not use USB Custom HID instead?

STM32Cube also offers a Custom HID class. That is appropriate when your device sends structured data to a dedicated desktop application, rather than pretending to be a conventional keyboard. However, USBD_CUSTOM_HID is not simply the keyboard HID class with another name. Its default descriptor, endpoint directions, report length and interface subclass/protocol differ from the classic HID mouse example. On many packages its default report has vendor-defined usages; an OS will not automatically convert such vendor reports into typing.

For a normal USB keyboard, the most direct route is to keep the HID interface’s Keyboard usage and proper report layout. For a configuration panel that reads knobs or transfers sensor measurements into custom software, vendor-defined Custom HID may be better. For plain serial text, the previous esp32.co.uk/ USB CDC tutorial (#041) is simpler. And if you need keyboard and CDC together over one cable, that is a composite USB device project requiring its own interface and endpoint planning, not an extra file added to a single-class example.

Troubleshooting checklist in the right order

There is little value in changing C keycodes while the PC still says Unknown USB Device. Start at power, PHY and clock; verify host enumeration; inspect class descriptors; check the application’s report state; and only then debug button bounce. The chart below keeps those layers separate. Be cautious with generated USB firmware since a small error in enumeration descriptors can make the board disappear from the OS, although SWD normally remains available for recovery.

STM32 Blue Pill USB HID keyboard troubleshooting USB clock descriptors endpoint size PB0 release busy handling
Diagnose USB electrical enumeration before debugging keyboard report content.

If the device appears and disappears repeatedly, inspect VBUS stability, the USB data cable, the D+ pull-up and the 48 MHz clock accuracy. Confirm that the MCU isn’t repeatedly resetting due to a power problem or watchdog. If the interface enumerates as keyboard but prints nothing, instrument the button state and the USBD_HID_SendReport result with a debugger rather than introducing additional USB text logging into a HID-only device. SWD remains the clean diagnostic interface for early HID development.

If a keystroke appears two or three times after a quick press, consider contact bounce and host key-repeat. They are different problems. Bounce produces multiple physical press/release transitions close together; repeat occurs when one press is held long enough for the OS to repeat it. A 25 ms debounce addresses the former, not the latter. If the host recognizes report changes but keyboard indicator LEDs don’t work, verify the one-byte SET_REPORT data-stage handler and the correct report type and ID, not the interrupt-IN endpoint.

Frequently asked questions

Can an STM32 Blue Pill act as a USB keyboard without an FTDI or CP2102 adapter?

Yes. The F103C8 has a native USB full-speed device peripheral on PA11/PA12, and a suitable HID firmware image can expose a keyboard interface. A USB-UART adapter is unnecessary for normal keyboard operation, but initial firmware flashing still needs SWD or a supported system bootloader interface. The factory F103C8 ROM does not make the USB socket a ready-made DFU programming port.

Why does my generated HID project identify itself as a mouse?

ST’s classic HID middleware often ships with a mouse interface protocol, four-byte endpoint and mouse report descriptor. You must replace all of those with consistent keyboard values. Changing only the data passed to USBD_HID_SendReport() does not change the USB interface advertised to the host.

Is the keyboard report ASCII or Unicode?

Neither. It contains HID keyboard usage IDs and modifier bits. The operating system applies the active keyboard layout to interpret them. To produce arbitrary text reliably across languages, implement explicit layout handling or a different application-facing transport rather than assuming that a US keyboard usage always represents the same visible character.

Why send a release report after each press?

USB keyboard input reports describe currently held keys. If you never send all-zero key slots after pressing a key, the host can continue believing it is down. A successful USB transfer only says the class accepted the packet for transport; it is not proof an application used the character.

Can I make six keys work at once?

With this standard eight-byte boot keyboard report, six regular-key slots are available in addition to modifier bits. For larger simultaneous combinations, a different report descriptor and protocol strategy is possible, but that isn’t compatible with every boot-protocol host.

Does this work in Windows 11, Linux and macOS?

A correctly implemented standard HID keyboard generally uses their built-in input drivers. The exact labels differ between operating systems, and a newly connected keyboard may trigger a host setup prompt. Hardware revisions, cable quality, descriptor correctness and keyboard layouts remain more important than the desktop OS version.

What is the difference between HID class and Custom HID middleware?

Classic HID can expose a conventional keyboard if you provide appropriate descriptors and eight-byte keyboard reports. Custom HID often starts with vendor-defined usages and may have both IN and OUT endpoints. You cannot rely on a vendor-defined report to appear as keyboard typing without a matching keyboard descriptor or application-layer driver.

Can the same USB connection provide HID keyboard and CDC serial simultaneously?

Yes, with a composite USB device that declares both interfaces and has valid endpoints, descriptor lengths and class state. That is more involved than modifying the single-class mouse example. Don’t assume you can paste the #041 CDC interface into this project without changing the USB configuration descriptor and registration flow.

Why does USB stop working after CubeMX regeneration?

Some generated project layouts replace or reference middleware files when you regenerate. That can revert keyboard-specific edits to the standard mouse class. Keep a patch for usbd_hid.h, usbd_hid.c, any control report handling and generated user code; review the diff each time.

Final project checks and next steps

The minimum end-to-end success criteria are specific: the Blue Pill boots from flash; its host sees a USB HID keyboard, not an unknown device or mouse; its interface describes a matching 63-byte report descriptor and eight-byte IN endpoint; the PB0 button reads HIGH normally and LOW when pressed; pressing produces the A usage; releasing produces an all-zero report; and the host doesn’t retain a stuck key. If you inspect LED Output reports, the control endpoint should accept the one-byte report too. These are expected outcomes to verify on hardware, not claims that this article’s sample has been lab-tested.

After that, extend the project to a three-key macro pad, a small keyboard matrix or a mode-selectable control panel. Keep the separation clear: GPIO scanning creates an input state, HID reports describe that state, and USB class middleware transports it. The rest of our esp32.co.uk/ STM32 series covers GPIO, EXTI, timers, DMA and USB CDC, which are useful building blocks for richer HID devices.

Technical references

Editorial note: This 2026 tutorial targets the conventional STM32F103C8T6 Blue Pill and classic STM32CubeF1 HAL USB middleware. ST may change code generation and middleware structure between package releases. Confirm the local source before applying snippets, retain SWD recovery, and bench-test the finished device before using it beyond a supervised demonstration.

CONTINUE EXPLORING / ESP32 & ELECTRONICS

Read next

Practical guides related to this article.

Explore the topic →
HARDWARE DECISION GUIDEWhich ESP32 board should you use?

Compare practical options before you choose hardware.

Compare options →