Перейти до основного вмісту

♆TinyUSB

Якщо у вас раптом завалявся мікроконтролер (далі просто МК) з підтримкою USB і давно було бажання спробувати як воно працює, то давайте розбиратися разом. Отже, нам знадобиться МК з USB, програмний стек TinyUSB та трохи власного часу. Будемо робити флешку, пульт керування музикою на ПК та віртуальний COM-порт.

TinyUSB — це бібліотека для роботи з USB периферією мікроконтролерів з акцентом на портованість, швидкість та безпечну роботу з пам'яттю завдяки статичному виділенню. Вона працює як міст між хардварною USB периферією нашого мікроконтролера та нашим кодом. Ця бібліотека спрощує роботу з USB контролером, ховаючи усі складнощі. Заявлена підтримка більше 50 лінійок МК різних виробників, серед яких наприклад лінійки від STM32, ESP32, RP2040, NRF, MSP та багато інших. Виробник Raspberry Pico (RP2040, RP2350) взагалі обрав TinyUSB як USB імплементацію за замовчуванням і помістив у свій Pico SDK. Інші виробники зазвичай мають свої власні пропрієтарні стеки, але якщо важлива портованість та open-source, то TinyUSB — саме той вибір.

Давайте поглянемо на інтеграцію TinyUSB на прикладі з платою на STM32WB55. Цей демо-проєкт розміщений на GitHub, в кінці цієї статті є відео-демка.

Хардвар

TinyUSB не займається безпосередньо генерацією того самого славнозвісного диференціального USB сигналу (D+ та D-), а покладається в цьому плані повністю на USB периферію нашого МК. Тобто МК уже повинен мати фізичний USB контролер, закладений виробником, тому для початку треба його увімкнути: налаштувати тактування та власне запустити. STMicroelectronics вже давно ввела практику додавання у свої МК з USB ще й високоточних внутрішніх тактових генераторів (HSI48), які обов'язкові для роботи USB, тому на щастя додавати окремо на плату його не обов'язково. Але недоліком внутрішніх тактових генераторів є їх залежність від температури навколишнього середовища та стабільної напруги. На щастя #2 і про це подбали в STM32, додавши спеціальну систему компенсації CRS (Clock Recovery System), яка пом'якшує цю проблему. Тобто перед інтеграцією TinyUSB треба це все налаштувати та запустити. Для мого STM32WB55 це виглядає таким чином:

void SystemClock_Config(void) {
RCC_OscInitTypeDef RCC_OscInitStruct = {0};
RCC_ClkInitTypeDef RCC_ClkInitStruct = {0};
RCC_CRSInitTypeDef RCC_CRSInitStruct = {0};

/** Configure the main internal regulator output voltage
*/
__HAL_PWR_VOLTAGESCALING_CONFIG(PWR_REGULATOR_VOLTAGE_SCALE1);

/** Initializes the RCC Oscillators according to the specified parameters
* in the RCC_OscInitTypeDef structure.
*/
RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_HSI48 |
RCC_OSCILLATORTYPE_HSI |
RCC_OSCILLATORTYPE_HSE;
RCC_OscInitStruct.HSEState = RCC_HSE_ON;
RCC_OscInitStruct.HSIState = RCC_HSI_ON;
RCC_OscInitStruct.HSI48State = RCC_HSI48_ON;
RCC_OscInitStruct.HSICalibrationValue = RCC_HSICALIBRATION_DEFAULT;
RCC_OscInitStruct.PLL.PLLState = RCC_PLL_ON;
RCC_OscInitStruct.PLL.PLLSource = RCC_PLLSOURCE_HSE;
RCC_OscInitStruct.PLL.PLLM = RCC_PLLM_DIV2;
RCC_OscInitStruct.PLL.PLLN = 8;
RCC_OscInitStruct.PLL.PLLP = RCC_PLLP_DIV2;
RCC_OscInitStruct.PLL.PLLR = RCC_PLLR_DIV2;
RCC_OscInitStruct.PLL.PLLQ = RCC_PLLQ_DIV2;
if (HAL_RCC_OscConfig(&RCC_OscInitStruct) != HAL_OK) {
Error_Handler();
}

/** Configure the SYSCLKSource, HCLK, PCLK1 and PCLK2 clocks dividers
*/
RCC_ClkInitStruct.ClockType = RCC_CLOCKTYPE_HCLK4 | RCC_CLOCKTYPE_HCLK2 |
RCC_CLOCKTYPE_HCLK | RCC_CLOCKTYPE_SYSCLK |
RCC_CLOCKTYPE_PCLK1 | RCC_CLOCKTYPE_PCLK2;
RCC_ClkInitStruct.SYSCLKSource = RCC_SYSCLKSOURCE_PLLCLK;
RCC_ClkInitStruct.AHBCLKDivider = RCC_SYSCLK_DIV1;
RCC_ClkInitStruct.APB1CLKDivider = RCC_HCLK_DIV1;
RCC_ClkInitStruct.APB2CLKDivider = RCC_HCLK_DIV1;
RCC_ClkInitStruct.AHBCLK2Divider = RCC_SYSCLK_DIV2;
RCC_ClkInitStruct.AHBCLK4Divider = RCC_SYSCLK_DIV2;

if (HAL_RCC_ClockConfig(&RCC_ClkInitStruct, FLASH_LATENCY_3) != HAL_OK) {
Error_Handler();
}

/** Enable the SYSCFG APB clock
*/
__HAL_RCC_CRS_CLK_ENABLE();

/** Configures CRS
*/
RCC_CRSInitStruct.Prescaler = RCC_CRS_SYNC_DIV1;
RCC_CRSInitStruct.Source = RCC_CRS_SYNC_SOURCE_USB;
RCC_CRSInitStruct.Polarity = RCC_CRS_SYNC_POLARITY_RISING;
RCC_CRSInitStruct.ReloadValue =
__HAL_RCC_CRS_RELOADVALUE_CALCULATE(48000000, 1000);
RCC_CRSInitStruct.ErrorLimitValue = 34;
RCC_CRSInitStruct.HSI48CalibrationValue = 32;

HAL_RCCEx_CRSConfig(&RCC_CRSInitStruct);
}
* Найлегше всього цей код згенерувати за допомогою STM32CubeMX — спеціального візуального конфігуратора для STM32

Мідлвар

Тепер нарешті можемо приступити до інтеграції мідлвару — TinyUSB. Назва мідлвар тут саме для того, щоб наголосити на місці TinyUSB у всій цій історії. Ця бібліотека розміщується посередині між хардваром — USB периферією МК — та власне кодом, який пише програміст. USB контролер МК відповідає за сигнальну частину, бібліотека — за протокол спілкування з хостом, наприклад ПК, куди буде підключатися наш USB девайс. Програмісту залишається написати firmware.

Бібліотека TinyUSB розміщена на GitHub, тому найбільш логічний спосіб її підключити в свій проєкт це додати як git підмодуль, ну або просто завантажити архів. А далі в залежності від вашого середовища розробки чи системи збірки додавайте .h та .c файли. В моєму випадку це cmake проєкт, тому просто підімкнув як git submodule та додав відповідні файли в CMakeLists.txt.

Далі потрібно зайнятися власне конфігурацією бібліотеки. Для цього нам знадобляться два конфігураційні файли — tusb_config.h та usb_descriptors.c. Перший, більш базовий і простий, описує наступні речі:

  • сімейство МК, яке буде використовуватись (OPT_MCU_STM32WB — у моєму випадку)
  • швидкісний режим роботи USB (USB Low Speed — 1.5 Mb/s, Full Speed — 12 Mb/s і т.д.)
  • чи буде використовуватися RTOS чи без RTOS (це важливо знати самій TinyUSB)
  • чи буде наш USB пристрій виступати хостом чи під'єднуваним пристроєм
  • які USB класи будуть використовуватись (наприклад MSC — як флешка, HID — як клавіатура, мишка чи геймпад; CDC — як COM port та інші)
  • розміри буферів, у які будуть поміщатися прийняті дані і дані для відправки

Найпростіше буде створити цей конфігураційний файл, скопіювавши з прикладів і підредагувати під свій МК і сценарії використання. Насправді це і є рекомендований спосіб від самих авторів TinyUSB. Можна наприклад увімкнути потрібний вам клас чи навіть кілька одразу (про це пізніше) і наш пристрій буде працювати одночасно наприклад як флешка і аудіо-пристрій.

Наступний конфігураційний файл — usb_descriptors.c — містить вже більш детальну конфігурацію, яка буде описувати наш пристрій хосту:

  • які класи пристрій реалізує
  • хто виробник пристрою
  • серійний номер
  • сила струму, яка буде споживатись пристроєм
  • тощо

Цей файл теж краще скопіювати з якогось прикладу і підлаштувати під себе. Взагалі на даному етапі бажано було б мати базове уявлення про роботу USB: дескриптори і їх типи, класи, інтерфейси та ендпоінти. Ця інформація виходить за рамки цієї статті, тому можете переглянути якесь відео чи курс (наприклад) на цю тему. Якщо цікавить сигнальна частина в деталях, то ось цікаве відео Ben Eater — How does a USB keyboard work?

Отже, для нашого прикладу створимо конфігурацію, де пристрій на базі STM32WB55 буде виступати як під'єднуваний пристрій до якогось хоста і буде працювати як COM-порт і як флешка:

#ifndef TUSB_CONFIG_H_
#define TUSB_CONFIG_H_

#ifdef __cplusplus
extern "C" {
#endif

//--------------------------------------------------------------------+
// Board Specific Configuration
//--------------------------------------------------------------------+

// RHPort number used for device can be defined by board.mk, default to port 0
#ifndef BOARD_TUD_RHPORT
#define BOARD_TUD_RHPORT 0
#endif

// RHPort max operational speed can defined by board.mk
#ifndef BOARD_TUD_MAX_SPEED
#define BOARD_TUD_MAX_SPEED OPT_MODE_FULL_SPEED
#endif

//--------------------------------------------------------------------
// Common Configuration
//--------------------------------------------------------------------

// defined by compiler flags for flexibility
#define CFG_TUSB_MCU OPT_MCU_STM32WB
#ifndef CFG_TUSB_MCU
#error CFG_TUSB_MCU must be defined
#endif

#ifndef CFG_TUSB_OS
#define CFG_TUSB_OS OPT_OS_NONE
#endif

#ifndef CFG_TUSB_DEBUG
#define CFG_TUSB_DEBUG 0
#endif

// Enable Device stack
#define CFG_TUD_ENABLED 1

// Default is max speed that hardware controller could support with on-chip PHY
#define CFG_TUD_MAX_SPEED BOARD_TUD_MAX_SPEED

/* USB DMA on some MCUs can only access a specific SRAM region with restriction
* on alignment. Tinyusb use follows macros to declare transferring memory so
* that they can be put into those specific section. e.g
* — CFG_TUSB_MEM SECTION : __attribute__ (( section(".usb_ram") ))
* — CFG_TUSB_MEM_ALIGN : __attribute__ ((aligned(4)))
*/
#ifndef CFG_TUSB_MEM_SECTION
#define CFG_TUSB_MEM_SECTION
#endif

#ifndef CFG_TUSB_MEM_ALIGN
#define CFG_TUSB_MEM_ALIGN __attribute__((aligned(4)))
#endif

//--------------------------------------------------------------------
// DEVICE CONFIGURATION
//--------------------------------------------------------------------

#ifndef CFG_TUD_ENDPOINT0_SIZE
#define CFG_TUD_ENDPOINT0_SIZE 64
#endif

//------------- CLASS -------------//
#define CFG_TUD_CDC 1
#define CFG_TUD_MSC 1
#define CFG_TUD_HID 0
#define CFG_TUD_MIDI 0
#define CFG_TUD_VENDOR 0

#define CFG_TUD_CDC_NOTIFY 1 // Enable use of notification endpoint

// CDC FIFO size of TX and RX
#define CFG_TUD_CDC_RX_BUFSIZE (TUD_OPT_HIGH_SPEED ? 512 : 64)
#define CFG_TUD_CDC_TX_BUFSIZE (TUD_OPT_HIGH_SPEED ? 512 : 64)

// CDC Endpoint transfer buffer size, default to max bulk packet size (HS 512,
// FS 64). Larger is faster. Larger RX_EPSIZE requires CFG_TUD_CDC_RX_NEED_ZLP =
// 1 and host ZLP support
#define CFG_TUD_CDC_RX_EPSIZE (TUD_OPT_HIGH_SPEED ? 512 : 64)
#define CFG_TUD_CDC_TX_EPSIZE (TUD_OPT_HIGH_SPEED ? 512 : 64)

// MSC Buffer size of Device Mass storage
#define CFG_TUD_MSC_EP_BUFSIZE 512

#ifdef __cplusplus
}
#endif

#endif /* TUSB_CONFIG_H_ */

Стосовно кількох класів. Так, USB протокол підтримує кілька класів одночасно. Це означає, що один девайс може одночасно відображатися і працювати в операційній системі хоста як кілька пристроїв. Наприклад, я впевнений, ви неодноразово могли бачити в Диспетчері пристроїв Windows при підключенні USB навушників/гарнітури з'являється не лише аудіо-пристрій, а і HID пристрій. З аудіо-пристроєм тут усе зрозуміло, а от HID пристрій це саме кнопки ваших навушників типу Старт/Павза, Вперед/Назад. Дані про натискання кнопок не можуть йти в аудіо пакетах, для них є окремий тип передачі даних. Запросто може бути і третій пристрій — наприклад мікрофон в навушниках. Тобто хоч USB це послідовний протокол, але виглядає як ніби все працює одночасно. Це досягається відносно високою швидкістю USB та пріоритетами, закладеними в протоколі: сигнали деяких класів важливіші в послідовному з'єднанні і їм виділяється більше пропускної здатності і квитки поближче до сцени.

Ще один приклад роботи кількох USB класів в одному пристрої це STM32 Nucleo борди. При підключенні до ПК відображається одразу 3 девайси: ST-Link, віртуальний COM порт та флешка. З ST-Link все зрозуміло, це — програматор. Віртуальний COM порт просто прокидає дані з/на UART основного МК Nucleo борди, що зручно для виведення логів наприклад. Тобто на основному МК пишете в UART і воно автоматично потрапляє через USB на ПК в PuTTY наприклад і таким чином не потрібно ніяких USB-Serial адаптерів/донглів. Якщо ж відкрити флешку, то там лежить кілька файлів. Вони насправді розміщуються в оперативній пам'яті МК ST-Link і містять інформацію про ревізію та посилання на якісь доки. Реальної флеш пам'яті за цими файлами немає, куди можна було б записати свої дані. Точніше, дані записати то можна, але запишуться вони в оперативну пам'ять і після перезавантаження МК все зітреться. Ми в нашому прикладі теж можемо легко створити таку флешку. Тобто виходить, що всього через один і той самий USB порт ми можемо логувати або керувати МК (UART RX/TX), завантажувати прошивку через ST-Link чи дебажити з брейкпоінтами. Простішого сетапу для вивчення Ембедеду годі шукати.

Фірмвар

Тепер, коли додали і налаштували TinyUSB, треба його подружити з одного боку з хардваром (USB контролер МК), а з другого — з нашим кодом (firmware). Тут усе працює на простому механізмі — на колбеках (callback). Якщо ви дочитали до цього місця і не кинули, то скоріше всього вам відома концепція колбеків. Це асинхронний механізм, який дозволяє реагувати на подію лише коли вона виникнула, а не опитувати весь час чи вона настала.

Спочатку треба підписати TinyUSB на події з USB контролера за допомогою STM32 HAL колбека. Таким чином TinyUSB буде приймати події, реєструвати їх у своїй пам'яті і більше нічого. За всіма заповідями ембедеду код в обробниках подій (IRQHandler) має виконуватися максимально швидко. Тобто TinyUSB лише фіксує факт прийому подій від USB контролера і відкладає власне реакцію на потім, щоб якнайшвидше закінчити IRQHandler. Далі, незалежно від того, чи у вас RTOS, чи просто супер-цикл, треба періодично викликати функцію tud_task(). Тут є один важливий нюанс: якщо у вас супер-цикл, то потрібно слідкувати, щоб котрась із задач в циклі не виконувалась занадто довго, тому що USB протокол дуже чутливий до таймінгів відповідей і функція tud_task() саме і відповідальна за комунікацію. Якщо у вас добре спроектована програма на якійсь RTOS, то з цим легше: виклик tud_task() буде відбуватися більш-менш періодично. Якщо ж у вас супер-цикл, то уважно слідкуйте як мінімум, щоб не користуватися HAL_Delay(). Будь-яка значна затримка відповіді хосту майже гарантовано означає відвал USB комунікації.

Наступні колбеки, які потрібно написати це колбеки для кожного класу пристрою. Для кожного класу вони свої, тому тут найкраще глянути на якийсь приклад конкретного класу, який вас цікавить. Наприклад для CDC класу (COM-порт) існують цілком очікувані tud_cdc_rx_cb та tud_cdc_tx_complete_cb, в яких програміст може зчитати отримані дані з хоста, або навпаки — дізнатись коли дані відправились до хоста. Для MSC класу (флешки) існують колбеки, які відповідають хосту на запитання про розмір пам'яті флешки чи коли хост хоче записати/зчитати за певною адресою: tud_msc_capacity_cb, tud_msc_read10_cb, tud_msc_write10_cb. Не всі колбеки є обов'язковими для імплементації, лише базові — без яких робота класу неможлива. Нижче наведено фрагменти коду для базового розуміння інтеграції TinyUSB, а також прикладну частину:

void tud_msc_capacity_cb(uint8_t lun, uint32_t *block_count,
uint16_t *block_size) {
(void)lun;
ensure_ready();
*block_count = DISK_SECTOR_COUNT;
*block_size = DISK_SECTOR_SIZE;
}

int32_t tud_msc_read10_cb(uint8_t lun, uint32_t lba, uint32_t offset,
void*buffer, uint32_t bufsize) {
(void)lun;
if (lba >= DISK_SECTOR_COUNT)
return -1;
memcpy(buffer, disk[lba] + offset, bufsize);
return (int32_t)bufsize;
}

int32_t tud_msc_write10_cb(uint8_t lun, uint32_t lba, uint32_t offset,
uint8_t*buffer, uint32_t bufsize) {
(void)lun;
if (lba >= DISK_SECTOR_COUNT)
return -1;
memcpy(disk[lba] + offset, buffer, bufsize);
return (int32_t)bufsize;
}

Отже, що відбувається в прикладі:

  • реєструємо пристрій з 3-ма класами одночасно: флешка, COM-порт і HID-пристрій з єдиною кнопкою Play/Pause
  • під флешку виділяємо 64КБ в оперативній пам'яті і додаємо колбеки читання і запису в пам'ять
  • перші області в пам'яті під флешку заповнюємо метаданими про флешку та простим файлом (поза темою статті)
  • для CDC класу (COM-порт) додаємо колбек на отримання даних: з хоста приходить "on" — вмикаємо LED
  • для HID класу теж додаємо колбек, в якому відповідаємо хосту командою Play/Pause

В результаті отримуємо закінчений пристрій з функціями:

  • Старт/Павза музики на хості (ПК наприклад)
  • файл-інструкція на "флешці" з поясненнями як користуватися пристроєм
  • управління LED та логування через COM-порт
  • працює однаково з Android та Linux хостами


В результаті в мене вийшло, що TinyUSB використала близько 12 КБ флеша (параметри компіляції -Os -flto) для 3-ох USB класів з базовою імплементацією колбеків. Це не так уже і мало, але враховуючи функціонал, який отримали (цілих 3 пристрої в одному), то ніби і не багато. Щодо оперативної пам'яті — все залежить від того, які розміри буферів виділяємо для роботи кожного класу. У всіх різні потреби та індивідуальні завдання, але у мене вийшло на 3 класи — 1.5-2 КБ оперативної пам'яті. Нагадаю, що пам'ять для TinyUSB виділяється лише статично.

🔚 Висновки

TinyUSB виглядає доволі зрілою і нескладною бібліотекою, яка кратно спрощує роботу з USB. Без неї довелося б набагато глибше розбиратися в специфікації USB. Заплативши невеликою кількістю флеш-пам'яті отримали досить багатий функціонал — цілих 3 пристрої. Лише потрібно пам'ятати, що USB досить вимогливий протокол стосовно таймінгів, тому цю відповідальність TinyUSB перекладає на програміста.




Дата публікації
2026.05.18