STM32CubeMXではHIDやマスストレージなどの一般的なUSBデバイスのひな形を作ることができますが、独自のディスクリプタを用いたデバイスを作ろうとするとどうしても低レベルの開発となってしまいます。

mbedやArduino互換プロジェクトを介してのアプローチという方法もありますが、TeenyUSBというオープンソースプロジェクトが、オリジナルのデバイス開発という点において今のところ最適だったので、実装方法をまとめてみました。

STM32CubeMXで新しいプロジェクトを作成したら、USBデバイス(F4シリーズならUSB_OTG_FS)を追加し、NVIC設定でUSBの割り込みを有効にします。ミドルウェアの指定は不要です。
TeenyUSBよりソースコードを入手したら、この中にある「usb_stack」内のファイル、および「usb_stack/demo/custom_bulk/teeny_usb_init.h」「usb_stack/demo/custom_bulk/teeny_usb_desc.c」「CubeMXF303/Inc/board_config.h」のファイルをプロジェクトにコピーします。
プロジェクトのプロパティを開き、追加した「Inc/teeny_usb」フォルダーをインクルードファイルのパスとして追加し、Symbolsに「NO_HOST」の定義を追加(これでUSBデバイスとしてのみ有効)します。
続いてコードの微調整です。「teeny_usb.c」にある「WEAK」を「__weak」に置換(ARMコンパイラ属性)し、「stm32f4xx_it.c」にある「OTG_FS_IRQHandler」関数を削除します(teeny_usbで定義済みの関数と競合するため)。

そしてmain.cに__weak定義の関数を上書きすることで、コールバックによる適切な処理ができるようにします。
/* Private includes ----------------------------------------------------------*/
/* USER CODE BEGIN Includes */
#include "teeny_usb.h"    // TeenyUSBのインクルード
/* USER CODE END Includes */

/* Private user code ---------------------------------------------------------*/
/* USER CODE BEGIN 0 */
// USBエンドポイントとバッファの定義
#define  TX_EP   PCD_ENDP1
#define  RX_EP   PCD_ENDP2
uint8_t buf[4096];
__IO uint32_t data_cnt = 0;

// データ送信完了コールバック
void tusb_on_tx_done(tusb_device_t* dev, uint8_t EPn)
{
  if(EPn == TX_EP){
    tusb_set_rx_valid(dev, RX_EP);
  }
}

// データ受信完了コールバック
int tusb_on_rx_done(tusb_device_t* dev, uint8_t EPn, const void* data, uint16_t len)
{
  if(EPn == RX_EP){
    data_cnt = len;
    return len;
  }
  return 0;
}

// USB初期化時のコールバック
void tusb_reconfig(tusb_device_t* dev)
{
  // call the BULK device init macro
  BULK_TUSB_INIT(dev);
  // setup recv buffer for rx end point
  tusb_set_recv_buffer(dev, RX_EP, buf, sizeof(buf));
  // enable rx ep after buffer set
  tusb_set_rx_valid(dev, RX_EP);
}

// ウェイト処理のコールバック(HALライブラリのウェイト関数を使用)
void tusb_delay_ms(uint32_t ms)
{
   HAL_Delay(ms);
}
/* USER CODE END 0 */

int main(void)
{

  /* USER CODE BEGIN 2 */
  // USBデバイスの作成
  tusb_device_t* dev = tusb_get_device(TEST_APP_USB_CORE);
  tusb_open_device(dev);
  /* USER CODE END 2 */

  /* Infinite loop */
  /* USER CODE BEGIN WHILE */
  char msg[] = "hello!";
  while (1)
  {
    /* USER CODE END WHILE */

    /* USER CODE BEGIN 3 */
    // 一定間隔ごとにホストへメッセージを送信
    HAL_Delay(3000);
    tusb_send_data(dev, TX_EP, msg, 6, 0);
  }
  /* USER CODE END 3 */
}
Nucleo-F446REでは、USBケーブルを写真のように接続します。
パソコン側でのlibusbの実装例です。Windows版ではパソコンにつないでいるUSB機器の列挙を行い、ベンダーIDとプロダクトIDの一致するデバイスに接続して、非同期処理でWinUSBのシミュレートを行います。
#include "libusb.h"
#include <iostream>

#define VENDORID 0x0483
#define PRODUCTID 0x0001

#define EP1 0x1
#define EP2 0x2

int count = 0;

bool need_exit = false;

void OnUsbReceived(libusb_transfer * transfer)
{
    if (transfer->status != LIBUSB_TRANSFER_COMPLETED) {
        fprintf(stderr, "transfer status %d?\n", transfer->status);
        need_exit = true;
        return;
    }

    printf("%d bytes received.\n", (int)transfer->length);
    count++;
    if (count > 500) need_exit = true;

    libusb_submit_transfer(transfer);
}

void OnUsbSend(libusb_transfer * transfer)
{

}

int main()
{
    int res;
    libusb_device_handle *device = nullptr;
    libusb_transfer *send = nullptr, *recv = nullptr;
    unsigned char send_buffer[32] = { 0x00 }, recv_buffer[32];

    res = libusb_init(NULL);

    libusb_device **devs, *dev = NULL;
    libusb_device *found = NULL;
    ssize_t cnt = libusb_get_device_list(NULL, &devs);

    int i = 0;
    while ((dev = devs[i++]) != NULL) {
        struct libusb_device_descriptor desc;
        int r = libusb_get_device_descriptor(dev, &desc);
        printf("%X/%X\n", desc.idVendor, desc.idProduct);
        if (desc.idVendor == VENDORID && desc.idProduct == PRODUCTID) {
            break;
        }
    }

    if(dev) i = libusb_open(dev, &device);

    libusb_free_device_list(devs, 1);

    if (i) goto EXIT;

    res = libusb_claim_interface(device, 0);
    if (res < 0) {
        fprintf(stderr, "usb_claim_interface error %d\n", res);
        goto EXIT;
    }
    printf("claimed interface\n");

    recv = libusb_alloc_transfer(0);
    libusb_fill_bulk_transfer(recv, device, LIBUSB_ENDPOINT_IN | EP1, recv_buffer, 32, OnUsbReceived, NULL, 0);

    send = libusb_alloc_transfer(0);
    libusb_fill_bulk_transfer(send, device, LIBUSB_ENDPOINT_OUT | EP2, send_buffer, 32, OnUsbSend, NULL, 0);

    res = libusb_submit_transfer(recv);

    strcpy_s((char*)send_buffer, 32, "hello!");
    res = libusb_submit_transfer(send);


    while (need_exit == false) {
        res = libusb_handle_events(NULL);
        if (res < 0) break;
    }


EXIT:
    if (send) {
        libusb_cancel_transfer(send);
        libusb_free_transfer(send);
    }
    if (recv) {
        libusb_cancel_transfer(recv);
        libusb_free_transfer(recv);
    }
    if(device) libusb_release_interface(device, 0);
    libusb_close(device);
    libusb_exit(NULL);

    return 0;
}