TinyUSB on STM32 MCU's with STM32CubeIDE

When using the USB peripheral on STM32 MCU's, you can choose between the vendor-provided USB stack and a third-party one like TinyUSB. The vendor-provided is inconvenient to use because even though it provides a selection of predefined classes, the code generation always overwrites any changes one might need to make to the stack to customize it or even to fix bugs in the implementation. TinyUSB has the advantage to be hardware-independent, is easy-to-use and highly customizable. Its disadvantage is that its integration into STM32CubeIDE and the HAL is not straight-forward. Hence this guide.

STMCubeMX configuration

TinyUSB uses the abstractions of the lower-level HAL for STM32 MCU's. This is configured in CubeMX. First, the USB_OTG_FS peripheral in the Connectivity section needs to be enabled. Set it to Device Only and most importantly check the global USB interrupt in NVIC Settings. This generates the OTG_FS_IRQHandler() interrupt callback in the stm32f<x>xx_it.c file, it is needed later.

Make sure that the USB Device in Middleware is not enabled.

Then the clock solver needs to be run the in Clock configuration. After that work is done in the CubeMX tool and the code can be generated.

STM32CubeIDE integration

First, the TinyUSB repository needs to be cloned into the project and enabled for linking and compilation.

In the project root directory, execute:

git clone https://github.com/hathach/tinyusb
# Or when the repository itself is managed with git
git submodule add https://github.com/hathach/tinyusb

TinyUSB itself uses some library submodules in lib, which need to be initialized.

cd tinyusb
git submodule update --init --recursive lib

It is recommended to use a released version, so the repository needs to be checked out to a release tag (0.14.0 at the time of writing):

git fetch --all --tags
git checkout 0.14.0

Then the linker and compiler need to be configured. First, open the projects properties window under Project->Properties and navigate to C/C++ Build->Settings. In the MCU GCC Compiler->Include Paths view you can add the path to the TinyUSB source directory to include its header files.
Make sure it is added for all configurations.

Then navigate to C/C++ General->Paths and Symbols and in Source Location add the TinyUSB source directory as well for all configurations.

At the time of writing with v0.14.0 there are two drivers for STM32 devices. Leaving them both enabled would result in Symbol already defined compilation errors. To prevent this, one of them needs to be disabled. This can be done by excluding its file with a filter. Select the added path and click on Edit Filters on the right side. Then add the portable/st/synopsys/dcd_synopsys.c to the filter.
This disables the older driver, and the newer driver in portable/synopsys/dwc2/dcd_dwc2.c gets used. By developer comment the older one will be removed at some point so this might not be necessary anymore in the future.

Code (CDC dual ports example)

To be able to use TinyUSB, it needs to be integrated in the existing code. For demonstration purposes the CDC dual ports example will get used.

tusb_config.h

TinyUSB expects the tusb_config.h configuration file to be present. In there the library and the peripheral is configured. Create it in Core/Inc.

The CFG_TUSB_MCU and CFG_TUSB_OS definitions are mandatory. Valid values for both definitions can be looked up in tusb_option.h.

// tusb_config.h

// ...

// Debugging
#define CFG_TUSB_DEBUG        3

// Enable device stack
#define CFG_TUD_ENABLED       1

// Endpoint size for full-speed
#define CFG_TUD_ENDPOINT0_SIZE    64

// Enables 2 interfaces of class CDC
#define CFG_TUD_CDC               2

// ...

usb_descriptor.c

Add usb_descriptors.c in Core/Src/. This file is for configuring the USB descriptors.

// usb_descriptor.c

#include "tusb.h"

// ...

#define _PID_MAP(itf, n)  ( (CFG_TUD_##itf) << (n) )
#define USB_PID           (0x4000 | _PID_MAP(CDC, 0) | _PID_MAP(MSC, 1) | _PID_MAP(HID, 2) | \
                           _PID_MAP(MIDI, 3) | _PID_MAP(VENDOR, 4) )
#define USB_VID   0xCafe
#define USB_BCD   0x0200

// ...

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

  // 1st CDC: Interface number, string index, EP notification address and size, EP data address (out, in) and size.
  TUD_CDC_DESCRIPTOR(ITF_NUM_CDC_0, 4, EPNUM_CDC_0_NOTIF, 8, EPNUM_CDC_0_OUT, EPNUM_CDC_0_IN, 64),

  // 2nd CDC: Interface number, string index, EP notification address and size, EP data address (out, in) and size.
  TUD_CDC_DESCRIPTOR(ITF_NUM_CDC_1, 4, EPNUM_CDC_1_NOTIF, 8, EPNUM_CDC_1_OUT, EPNUM_CDC_1_IN, 64),
};

// array of pointer to string descriptors
char const* string_desc_arr [] =
{
  (const char[]) { 0x09, 0x04 }, // 0: is supported language is English (0x0409)
  "TinyUSB",                     // 1: Manufacturer
  "Private Inc",                 // 2: Product
  "123456",                      // 3: Serials, should use chip ID
  "TinyUSB CDC",                 // 4: CDC Interface
};

// ...

stm32fxxx_it.c

As mentioned in the beginning, the code inside the global interrupt handler needs to be modified. In there tud_int_handler() is called to hand the handling of the interrupt to TinyUSB. By returning early, the "normal" interrupt handler code is prevented to be run, because it could interfere with TinyUSB.

// stm32fxxx_it.c

//...

void OTG_FS_IRQHandler(void)
{
  /* USER CODE BEGIN OTG_FS_IRQn 0 */
  return tud_int_handler(BOARD_DEVICE_RHPORT_NUM);

  /* USER CODE END OTG_FS_IRQn 0 */
  HAL_PCD_IRQHandler(&hpcd_USB_OTG_FS);
  /* USER CODE BEGIN OTG_FS_IRQn 1 */

  /* USER CODE END OTG_FS_IRQn 1 */
}

// ...

main.c

Then finally in main.c the library is initialized with tusb_init() after the clock and peripheral initialization of the HAL. In the main loop tud_task() is repeatedly called to handle all pending events in TinyUSB's internal event queue.

For the CDC example there is also some code to echo the received input from either CDC interface to both in either lower- and uppercase.

// main.c

// ...
#include "ctype.h"
#include "tusb_config.h"
#include "tusb.h"
// ...

static void echo_serial_port(uint8_t itf, uint8_t buf[], uint32_t count);
static void cdc_task(void);

int main(void) {
  // ...

	tusb_init();

	while (1) {
		tud_task(); // TinyUSB device task
		
		cdc_task(); // CDC example
		// ...
	}
}

// ***

// echo to either Serial0 or Serial1.
// with Serial0 as all lower case, Serial1 as all upper case.
static void echo_serial_port(uint8_t itf, uint8_t buf[], uint32_t count) {
  uint8_t const case_diff = 'a' - 'A';

  for (uint32_t i = 0; i < count; i++) {
    if (itf == 0) {
      // echo back 1st port as lower case
      if (isupper(buf[i]))
        buf[i] += case_diff;
    } else {
      // echo back 2nd port as upper case
      if (islower(buf[i]))
        buf[i] -= case_diff;
    }

    tud_cdc_n_write_char(itf, buf[i]);
  }
  tud_cdc_n_write_flush(itf);
}

// USB CDC
static void cdc_task(void) {
  uint8_t itf;

  for (itf = 0; itf < CFG_TUD_CDC; itf++) {
    // connected() check for DTR bit
    // Most but not all terminal client set this when making connection
    // if ( tud_cdc_n_connected(itf) )
    {
      if (tud_cdc_n_available(itf)) {
        uint8_t buf[64];
        uint32_t count = tud_cdc_n_read(itf, buf, sizeof(buf));
        // echo back to both serial ports
        echo_serial_port(0, buf, count);
        echo_serial_port(1, buf, count);
      }
    }
	}
}

// ...

Testing

Before running the code, it is advisable to enable debugging and logging at first by setting CFG_TUSB_DEBUG to a value greater than zero in the configuration file. By default TinyUSB is logging with printf(). To be able to inspect these entries over e.g. UART, _write() can be overwritten with something like:

// main.c

// ...

int _write(int file, char *ptr, int len) {
  return HAL_UART_Transmit(&huart2, (uint8_t*) ptr, len, HAL_MAX_DELAY);
}

// ...

This is it! The code should now compile and run on the MCU. Once the USB peripheral is connected to the host, a USB device should appear with Vendor ID 0xCafe and two CDC devices. They should appear as virtual-COM Ports in the "Device Manager" on Windows or by inspecting the output of lsusb on Linux.

To connect to both CDC's a serial terminal tool can be used, for example tio. On linux execute tio /dev/ttyACM<x> for both devices.
Then, whenever something is typed into one of the terminals it should be echoed by both in lower- and uppercase.