Building a Custom USB HID Device with RP2040 and TinyUSB

Let’s start from afar and go back 30 years to the time of the first Pentium processors.

Back then, every device on a PC had its own separate interface:

  • DIN — for the keyboard.

  • A narrow COM port — for the mouse.

  • A wide COM port — for the modem.

  • Branded computers also had PS/2 ports — separate for the keyboard and mouse.

  • A parallel port (LPT) — for a printer, scanner, or external CD-ROM.

  • External SCSI — for storage drives, CD-ROMs, or scanners.

  • MIDI/Game port — for musical equipment and gamepads.

And these were just the standard interfaces. In addition to them, many manufacturers created their own proprietary interfaces for their equipment.

This was inconvenient for users and also created problems for manufacturers. To solve this problem, the USB consortium of hardware and software manufacturers was created. Its goal was to implement a single universal bus for connecting wide range of external devices.

Of course, no one expected the transition to be quick. Users still had a lot of old hardware and software (like Windows 95 or MS-DOS) that did not support USB.

And if nothing could be done about the hardware, some solutions did exist for the software. For example, a special Boot Protocol was added to the standard for keyboards. It guarantees that the keyboard will function in any environment without requiring specialized drivers. In this mode, the device sends data in a strictly fixed format that any host can understand. Thus, even the BIOS could use a built-in simplified driver and emulate the operation of a classic keyboard.

The main limitation of the protocol is the ability to simultaneously transmit up to 6 scan-codes of regular keys, as well as the state of 8 modifier keys (Ctrl, Shift, Alt, GUI). This feature is known as 6KRO (6-Key Rollover), which is the standard for most office and non-professional gaming keyboards.

In this article, I want to show how to use the RP2040 microcontroller to create a custom USB device. The goal will be to emulate a keyboard operating specifically in Boot Protocol mode. To implement the USB stack, we will use the TinyUSB library, which greatly simplifies low-level USB interaction.

Setting up the RP2040 project with TinyUSB

First, let’s create a new project using the PicoSDK tools. In the configuration window, you need to set the initial parameters.


Follow these steps:

  • Project Name: Specify the project name, for example, UsbHid.

  • Board Type: Select your board type. In my case, it’s the standard Raspberry Pi Pico.

  • Features: In this section, uncheck all default boxes. We are creating a minimalistic project and do not need extra libraries like printf or UART, which are enabled by default.

  • Code Language: Be sure to check the “Generate C++ code” box.

After configuring, press “OK” to generate the project structure.

After creating an empty project, we get a folder with several files. For now, the important file for us is the project compilation configuration file - CMakeLists.txt:

cmake_minimum_required(VERSION 3.13)

set(CMAKE_C_STANDARD 11)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

...
====================================================================================
set(PICO_BOARD pico CACHE STRING "Board type")

include(pico_sdk_import.cmake)

project(UsbHid C CXX ASM)
pico_sdk_init()
add_executable(UsbHid UsbHid.cpp )
pico_set_program_name(UsbHid "UsbHid")
pico_set_program_version(UsbHid "0.1")
pico_enable_stdio_uart(UsbHid 0)
pico_enable_stdio_usb(UsbHid 0)
target_link_libraries(UsbHid
        pico_stdlib)
target_include_directories(UsbHid PRIVATE
        ${CMAKE_CURRENT_LIST_DIR}
)
pico_add_extra_outputs(UsbHid)

Now we need to add two libraries to the dependencies: tinyusb_device and tinyusb_board. To do this, we will edit the target_link_libraries directive:

target_link_libraries(UsbHid 
    pico_stdlib 
    tinyusb_device
    tinyusb_board
    )

Attempting to compile the project at this stage will result in an error, as the compiler will not be able to find the necessary configuration files:

tusb_option.h:243:12: fatal error: tusb_config.h: No such file or directory
  243 |   #include "tusb_config.h"

This happens because we need to add files with the USB device configuration description. We will not create them from scratch, but will take ready-made samples from the library’s official repository. We will need three files:

  • tusb_config.h

  • usb_descriptors.h

  • usb_desctiptors.c

These files can be found in the examples for the TinyUSB library at this link: https://github.com/hathach/tinyusb/tree/master/examples/device/hid_boot_interface/src.

First, copy tusb_config.h into the project. In the original example from the TinyUSB repository, two USB devices are emulated — a keyboard and a mouse. For simplicity, we will leave only the keyboard, so we need to change the corresponding value to 1.

#define CFG_TUD_HID               1

As the next step, copy usb_descriptors.h. In this file, you need to remove the mention of the mouse (ITF_NUM_MOUSE) and add the polling interval (POLLING_INTERVAL_MS) for the keyboard:

enum
{
  ITF_NUM_KEYBOARD,
  ITF_NUM_MOUSE,
  ITF_NUM_TOTAL
};

#define POLLING_INTERVAL_MS 10

The last file, usb_descriptors.c, also needs to be copied entirely into your project.

Now, let’s add usb_descriptors.c to the list of files for compilation. To do this, edit the add_executable directive in the CMakeLists.txt file:

add_executable(HelloUsb HelloUsb.cpp usb_descriptors.c)

If you try to compile the project now, you will get an error.

usb_decriptors.c:118:22: error 'ITF_NUM_MOUSE' undeclared here (not in a function); 
did you mean 'ITF_NUM_TOTAL'?

The compilation error occurs because references to ITF_NUM_MOUSE, which we removed from the .h file, still remain in usb_descriptors.c. To fix this, you need to carefully edit usb_descriptors.c and remove all code related to the mouse.

First, find the block where the HID report descriptors are defined. Here, you need to completely remove the desc_hid_mouse_report array and simplify the tud_hid_descriptor_report_cb function so that it only returns the keyboard descriptor.

uint8_t const desc_hid_keyboard_report[] =
{
  TUD_HID_REPORT_DESC_KEYBOARD()
};

uint8_t const desc_hid_mouse_report[] =
{
  TUD_HID_REPORT_DESC_MOUSE()
};

uint8_t const * tud_hid_descriptor_report_cb(uint8_t instance)
{
  return (instance == 0) ? desc_hid_keyboard_report : desc_hid_mouse_report;
  return desc_hid_keyboard_report;
}

Next, the main configuration descriptor (desc_configuration) needs to be updated. We will remove everything related to the mouse: the length definition, the endpoint number, and the interface descriptor itself.

So this block:
#define CONFIG_TOTAL_LEN  (TUD_CONFIG_DESC_LEN + 2*TUD_HID_DESC_LEN)

#if CFG_TUSB_MCU == OPT_MCU_LPC175X_6X || CFG_TUSB_MCU == OPT_MCU_LPC177X_8X || CFG_TUSB_MCU == OPT_MCU_LPC40XX
  // LPC 17xx and 40xx endpoint type (bulk/interrupt/iso) are fixed by its number
  // 1 Interrupt, 2 Bulk, 3 Iso, 4 Interrupt, 5 Bulk etc ...
  #define EPNUM_KEYBOARD   0x81
  #define EPNUM_MOUSE      0x84
#else
  #define EPNUM_KEYBOARD   0x81
  #define EPNUM_MOUSE      0x82
#endif

uint8_t const desc_configuration[] =
{
  // Config number, interface count, string index, total length, attribute, power in mA
  TUD_CONFIG_DESCRIPTOR(1, ITF_NUM_TOTAL, 0, CONFIG_TOTAL_LEN, TUSB_DESC_CONFIG_ATT_REMOTE_WAKEUP, 100),

  // Interface number, string index, protocol, report descriptor len, EP In address, size & polling interval
  TUD_HID_DESCRIPTOR(ITF_NUM_KEYBOARD, 
  	0, HID_ITF_PROTOCOL_KEYBOARD, 
    sizeof(desc_hid_keyboard_report), EPNUM_KEYBOARD, CFG_TUD_HID_EP_BUFSIZE, 10),

  // Interface number, string index, protocol, report descriptor len, EP In address, size & polling interval
  TUD_HID_DESCRIPTOR(ITF_NUM_MOUSE, 0, HID_ITF_PROTOCOL_MOUSE, 
  	sizeof(desc_hid_mouse_report), EPNUM_MOUSE, CFG_TUD_HID_EP_BUFSIZE, 10)
};

should be replaced with:

#define CONFIG_TOTAL_LEN  (TUD_CONFIG_DESC_LEN + TUD_HID_DESC_LEN) // only one TUD_HID_DESC_LEN

#define EPNUM_KEYBOARD   0x81 // We have only one endpoint for the HID keyboard

uint8_t const desc_configuration[] =
{
  // Config number, interface count, string index, total length, attribute, power in mA
  TUD_CONFIG_DESCRIPTOR(1, ITF_NUM_TOTAL, 0, CONFIG_TOTAL_LEN, TUSB_DESC_CONFIG_ATT_REMOTE_WAKEUP, 100),

  // Interface number, string index, protocol, report descriptor len, EP In address, size & polling interval
  TUD_HID_DESCRIPTOR(
    ITF_NUM_KEYBOARD,
    0,
    HID_ITF_PROTOCOL_KEYBOARD,
    sizeof(desc_hid_keyboard_report),
    EPNUM_KEYBOARD,
    CFG_TUD_HID_EP_BUFSIZE,
    POLLING_INTERVAL_MS // Let's use constant from the usb_descriptors.h
  )
};

After these changes, the project should compile without errors.

Now let’s move on to editing the main project file - UsbHid.cpp. First, we need to add the necessary header files for working with TinyUSB:

#include "bsp/board_api.h"
#include "tusb.h"
#include "usb_descriptors.h"
Next, in the main() function, we should add two calls to initialize the board (board_init()) and the TinyUSB stack (tusb_init()):
int main()
{
    stdio_init_all();
    board_init();
    tusb_init();

After these changes, the compilation will again produce errors.

The first error will be related to the missing CFG_TUSB_RHPORT0_MODE configuration. We need to specify the mode and speed at which the microcontroller’s USB port will operate.

To do this, open the tusb_config.h file and add the following line to the “Board Specific Configuration” section:

#define CFG_TUSB_RHPORT0_MODE OPT_MODE_DEVICE | OPT_MODE_FULL_SPEED

Here we specify two options:

  • OPT_MODE_DEVICE - the port will operate in device mode, not host mode.

  • OPT_MODE_FULL_SPEED - the port’s operating speed will be 12 Mbit/s (USB FS). This is the maximum speed supported by the RP2040.

And the second error is about missing functions: tud_hid_get_report_cb and tud_hid_set_report_cb. These are mandatory callback functions that the host can use to work with reports.

For our simple device, they can be left empty. Copy their implementation from the official example and add them inside the UsbHid.cpp file:

uint16_t tud_hid_get_report_cb(
    uint8_t instance,
    uint8_t report_id,
    hid_report_type_t report_type,
    uint8_t* buffer,
    uint16_t reqlen)
{
  (void) instance;
  (void) report_id;
  (void) report_type;
  (void) buffer;
  (void) reqlen;
  return 0;
}

void tud_hid_set_report_cb(
    uint8_t instance,
    uint8_t report_id,
    hid_report_type_t report_type,
    uint8_t const* buffer,
    uint16_t bufsize)
{
    (void) instance;
    (void) report_id;
    (void) report_type;
    (void) buffer;
    (void) bufsize;
}

After adding these functions, the project should finally compile successfully, but the device still isn’t performing any useful actions. Let’s add the logic for initializing and operating the HID keyboard.

Before we can start sending data, we need to wait until the USB HID interface is fully ready to operate. To do this, let’s add a waiting loop into the main() function after tusb_init() call:

int main()
{
    stdio_init_all();
    board_init();
    tusb_init();

    // Lets' wait until the HID interface is ready
    while (!tud_hid_ready())
    {
        tud_task(); // this function we should call periodically, to handle USB functionality
        sleep_ms(POLLING_INTERVAL_MS);
    }
    
    // small delay after initialization
    sleep_ms(10 * POLLING_INTERVAL_MS);

Now, let’s implement the main infinite loop. In it, we will periodically send a keyboard status report. At this stage, we will just send an empty buffer, signaling to the system that no key is pressed.

    // Scancodes buffer, up to 6 scancodes
    uint8_t scancodes[6] = {0};

    while (true) 
    {
        tud_task(); // continue to call the TinyUSB background task

        // Sending the keyboard status report
        tud_hid_keyboard_report(
            ITF_NUM_KEYBOARD, // USB HID Interface number
            0,                // Key modifiers (Shift, Ctrl, Alt)
            scancodes         // Scancodes buffer
        );

        sleep_ms(POLLING_INTERVAL_MS);
    }

If you now upload the compiled firmware to your RP2040, the operating system should automatically recognize the new device as a standard USB keyboard.

In Linux, it looks something like this:

And the last step, let’s add the final functionality: we’ll have our virtual keyboard automatically send an ‘A’ keypress every two seconds. This will allow us to visually verify that everything is working as it should.

To do this, we will need to track time using the board_millis() function.

    uint32_t lastTime = 0;
    uint8_t resetCounter = 0;

    while (true) {
        uint32_t currentTime = board_millis();
        tud_task();
        // Every 2 seconds, simulate pressing the 'A' key
        if (currentTime - lastTime > 2000) {
            scancodes[0] = HID_KEY_A;
            // Set a counter to "release" the key after 10 iterations
            resetCounter = 10;
            lastTime = currentTime;
        }
        if (resetCounter > 0) {
            resetCounter --;
            if (resetCounter == 0) {
                // Let's clean the scan code, i.e. key was released
                scancodes[0] = 0;
            }
        }
        tud_hid_keyboard_report(
            ITF_NUM_KEYBOARD,
            0,
            scancodes
        );
        sleep_ms(POLLING_INTERVAL_MS);
    }

Now, if you upload the firmware and open any text editor, you will see the letter “a” being automatically typed every two seconds. Congratulations, you have created your HID device!

And for me, the letter started typing directly in VSCode:



Comments

Popular posts from this blog

How to use SPIFFS for ESP32 with Platform.IO

Configuring LED Indicators on Orange Pi Zero 3

ESP8266 module with OLED screen (HW-364A)