Разработка USB-устройств на МК Миландр с TinyUSB: 7 готовых примеров

ID статьи: 69543
Дата последнего изменения: 23.10.2026 10:31:46
Подключить отладочную плату на базе микроконтроллера К1986ВЕ92FI к компьютеру, и она появится в системе, как обычная флэшка. Затем открыть на этой флэшке файл формата .html в браузере, и страница начнет управлять периферией на плате с микроконтроллером (управлять светодиодами платы, показывать нажатия кнопок и показания датчика температуры внутреннего АЦП микроконтроллера) - всё это без драйверов, без установки сторонних программ и без доступа в интернет. Та же плата за пару минут превращается в мышь, клавиатуру или виртуальный COM-порт - достаточно всего лишь перепрошить микроконтроллер другим уже готовым примером. Интересно?

Всё это работает на открытом USB-стеке TinyUSB, для которого команда "Миландр" подготовила собственный драйвер контроллера USB. В статье показано, что разработка USB-устройства на микроконтроллере "Миландр" сводится к описанию дескрипторов и нескольким функциям обратного вызова: всю сложную работу с протоколом USB возьмет на себя стек.

В статье будут рассмотрены следующие вопросы:

1. Что представляет собой стек TinyUSB и почему он так популярен.
2. Как устроен стек и от каких файлов зависит проект.
3. Порт TinyUSB для контроллера USB К1986ВЕ9х и К1986ВЕ1х: особенности контроллера и их решение в драйвере.
4. Подключение порта к стеку TinyUSB, скачанному с GitHub.
5. О подготовленном большом проекте для IDE Keil.
6. Классы USB, используемые в примерах.
7. Разбор каждого примера: COM-порт, мышь, клавиатура, флэшка, WebUSB и составные устройства.
8. Проверка примеров тестами энумерации и нагрузки.
9. Что учесть при переходе от примера к собственному устройству.

1. Что представляет собой стек TinyUSB и почему он так популярен

TinyUSB - открытый кроссплатформенный USB-стек для микроконтроллеров, распространяется под лицензией MIT. Это значит, что его можно использовать в коммерческих изделиях без раскрытия собственного исходного кода - достаточно сохранить текст лицензии. Проект на GitHub является очень популярным, а в списке поддерживаемых платформ - более 50 семейств микроконтроллеров.

На TinyUSB построена работа с USB в известных инструментах разработки. Например, USB-стек в фреймворке ESP-IDF компании Espressif построен вокруг TinyUSB, в официальном SDK для микроконтроллеров Raspberry Pi он входит в список сторонних библиотек и активно используется сообществом. Другими словами, это широко используемый и хорошо проверенный на практике код.

Но широкое распространение - не единственное преимущество. Для микроконтроллера с небольшим объёмом памяти важны следующие три свойства стека:

• Динамическое выделение памяти не используется, все буферы статические, и их размеры задаются в конфигурации.
• Обработчик прерывания только ставит события в очередь, а вся обработка идёт в функции tud_task(), которая вызывается из главного цикла. Поэтому стек работает без операционной системы, но при необходимости - и под FreeRTOS, и под другими RTOS.
• Ненужные классы отключаются макросами и в прошивку не попадают.

В режиме устройства (работа с микроконтроллером в рамках статьи представлена именно в режиме "Device") TinyUSB поддерживает классы: CDC, HID (в том числе клавиатура, мышь, геймпад), MSC, MIDI, Audio, DFU, USBTMC, сетевые классы RNDIS/ECM/NCM, MTP, принтер, а также Vendor-класс, в том числе с поддержкой WebUSB. 
В примерах статьи используется TinyUSB версии 0.21.0 - актуальной на октябрь 2026 года. При этом изложенные в статье пояснения по работе помогут адаптировать примеры к будущим версиям стека.
Возникает вопрос: зачем писать порт, если в официальной библиотеке SPL уже есть драйвер для работы с USB? Несмотря на то, что этот драйвер стабилен, он реализует только класс CDC (виртуальный COM-порт). Для флэшки (MSC), мыши и клавиатуры (HID), WebUSB или составного устройства из нескольких классов пришлось бы самостоятельно писать дескрипторы, обработку запросов и сами классы. TinyUSB избавляет от этой работы: после того как реализован порт, то есть драйвер контроллера, все классы стека становятся доступны без изменений в нём.

2. Как устроен стек и от каких файлов зависит проект

TinyUSB разделён на слои, и для переноса на новый микроконтроллер нужно написать только нижний из них - драйвер контроллера устройства (Device Controller Driver или DCD). Ядро стека и драйверы классов не зависят от аппаратной части и обращаются к контроллеру USB только через функции dcd_*, объявленные в файле src/device/dcd.h.


Рисунок 1 - Слои стека TinyUSB в проекте

На рисунке 1 показаны слои стека в нашем проекте: всё, что выше интерфейса DCD, взято из TinyUSB; в файлы стека внесены лишь две небольшие правки для сборки в Keil (таблица 4 ниже).

Приложение со стеком связывают три вещи:

• Файл конфигурации tusb_config.h - в нём включаются нужные классы и задаются размеры буферов.

• Дескрипторы usb_descriptors.c - сообщают хосту, что за устройство к нему подключено.

• Функции обратного вызова классов (tud_cdc_rx_cb(), tud_msc_read10_cb() и другие) - через них стек передаёт приложению данные и запросы хоста.

В таблице 1 перечислены файлы, которые добавляются в проект. Из всего каталога стека с названием src вместе с портом нужно всего девять исходных файлов, а драйверы других микроконтроллеров из каталога src/portable не нужны.

Таблица 1 - Файлы проекта

Файл Слой Назначение
main.c, usb_descriptors.c, tusb_config.h Приложение Логика устройства, дескрипторы, конфигурация стека
src/class/cdc/cdc_device.c Класс Виртуальный COM-порт
src/class/hid/hid_device.c Класс Мышь, клавиатура и другие HID-устройства
src/class/msc/msc_device.c Класс Накопитель (флэшка)
src/class/vendor/vendor_device.c Класс Собственный протокол, WebUSB
src/device/usbd.c Ядро Энумерация, стандартные запросы, очередь событий
src/tusb.c, src/common/tusb_fifo.c Ядро Инициализация стека, кольцевые буферы
src/typec/usbc.c Ядро Поддержка USB Type-C, в примерах не используется
src/tusb_MDR32Fx.c Порт (DCD) Драйвер для работы контроллера USB со стеком

Помимо исходников стека, проекту нужна библиотека SPL: компоненты RST_CLK, PORT и USB (первичный драйвер MDR32FxQI_usb.c (не файлы библиотеки для реализации класса CDC), где приведены базовые функции по работе с USB: сброс, настройка конечных точек и т.д) - а для примеров с WebUSB ещё и ADC. В IDE Keil они подключаются галочками в окне "Manage Run-Time Environment".

3. Порт TinyUSB для контроллера USB К1986ВЕ9х и К1986ВЕ1х: особенности контроллера и их решение в драйвере

Весь порт умещается в один файл src/tusb_MDR32Fx.c объёмом около 600 строк и подходит для микроконтроллеров К1986ВЕ9х и К1986ВЕ1х (повторно отметим, что примеры созданы для К1986ВЕ92FI, тем не менее, их легко можно адаптировать для работы с любым микроконтроллером из семейства К1986ВЕ1х). Для работы с регистрами контроллера используется библиотека SPL (MDR32FxQI_usb.с, MDR32FxQI_rst_clk.с). Порт реализует функции из таблицы 2 - именно их вызывает ядро стека.

Таблица 2 - Функции драйвера контроллера

Функция Назначение 
dcd_init() Тактирование (ядро работает на частоте HSE*10, USB на частоте HSE*6 = 48 МГц при HSE = 8 МГц), режим устройства Full Speed, подключение необходимых подтяжек для организации шины USB
dcd_int_enable(), dcd_int_disable() Разрешение и запрет прерывания USB_IRQn
dcd_set_address() Запоминает адрес, он применяется после подтверждения статуса SET_ADDRESS
dcd_edpt_open(), dcd_edpt_close(), dcd_edpt_close_all() Включение/выключение работы конечных точек
dcd_edpt_xfer() Передача или приём пакета по запросу стека
dcd_edpt_stall(), dcd_edpt_clear_stall() Установка и снятие состояния STALL
dcd_int_handler(), USB_IRQHandler() Сброс шины, завершение транзакций, обработка ключевых событий для стека

В таблице 3 приведены особенности контроллера USB, которые пришлось учесть при портировании.

Таблица 3 - Особенности контроллера и их решение в порте

Особенность контроллера Решение в порте
Всего четыре конечные точки (EP0-EP3) с FIFO по 64 байта При передаче больших объемов данных (например, дескрипторы на IN) стек делит данные на порции
Один бит готовности EPRDY на оба направления (IN и OUT) EPRDY (точка готова) взводится только тогда, когда стек поставил передачу в очередь, до этого хост получает NAK
Регистры состояния TS/STS не очищаются и хранят результат последней транзакции Транзакция считается завершенной, только если порт сам взводил EPRDY, а теперь он сброшен
Одно прерывание SCTDONE (окончена передача) на все точки В обработчике проверяется каждая точка
OUT-пакет может прийти раньше, чем стек выдал буфер Пакет остаётся в RX FIFO и отдаётся стеку при следующем вызове dcd_edpt_xfer()


Главное правило работы порта представлено во фрагменте кода 1: порт сам запоминает, что взвёл точку, и по этому признаку решает, что транзакция завершилась.

Фрагмент кода 1 - Взведение точки и проверка завершения транзакции

// Взвести EPRDY - точка готова к обмену
static void ep_set_ready(uint8_t ep)
{
    USB_SetSEPxCTRL((USB_EP_TypeDef)ep, USB_SEPx_CTRL_EPRDY_Ready);
    ep_armed[ep] = true;
}

// Прошла ли транзакция с момента взведения EPRDY
static bool ep_transaction_done(uint8_t ep)
{
    if (ep_armed[ep] && !(USB_GetSEPxCTRL((USB_EP_TypeDef)ep) & USB_SEPx_CTRL_EPRDY_Ready)) {
        ep_armed[ep] = false;
        return true;
    }
    return false;
}

Ещё одна тонкость касается нулевой точки. Обычно TinyUSB обрабатывает запрос SETUP позже, в tud_task(). Но если EP0 взведена раньше, чем ответ попал в FIFO, контроллер отправит хосту пустой пакет, и длинный дескриптор оборвётся. Поэтому для EP0 порт вызывает tud_task() прямо в обработчике прерывания и взводит точку уже с готовым ответом.

Подчеркнем, что порт работает без операционной системы (CFG_TUSB_OS = OPT_OS_NONE). Функция tusb_time_millis_api(), которую стек требует в этом режиме, в порте возвращает 0: в примерах время стеком не используется.

4. Подключение порта к стеку TinyUSB, скачанному с GitHub

Чтобы собрать проект с нуля или обновить стек до новой версии, достаточно выполнить следующие шаги.

1. Скачать TinyUSB с GitHub: архив релиза или клон репозитория. Из него нужен только каталог src - скопировать его в проект и внесите правки из таблицы 4.
2. Положить файл порта tusb_MDR32Fx.c в каталог src (в нашем проекте он лежит рядом с tusb.c).
3. Добавить в проект Keil файлы из таблицы 1. Классы, которые вам не нужны, можно не добавлять, а можно оставить: класс, выключенный в tusb_config.h, компилируется в пустой объектный файл.
4. В Options for Target -> C/C++ -> Include Paths указать каталог с файлом tusb_config.h, а также src, src\common, src\device, src\osal, src\typec и каталоги используемых классов (например, src\class\cdc).
5. В Options for Target -> C/C++ -> Define добавить макрос CFG_TUSB_MCU (подробнее в примечании ниже).
6. В Manage Run-Time Environment подключить компоненты SPL: Drivers -> RST_CLK, PORT и USB.
7. Создать tusb_config.h и usb_descriptors.c (проще всего взять их из подходящего примера) и вызвать инициализацию стека в main() (фрагмент кода 3).
Отдельного внимания заслуживает макрос CFG_TUSB_MCU. Стек требует, чтобы он был определён, но собственного значения для микроконтроллеров "Миландр" в TinyUSB нет. Макрос, заданный в Keil без значения, равен 1, и стек считает его идентификатором OPT_MCU_LPC11UXX. Для нас это безопасно: драйвер LPC в проект не добавляется, а стек получает два подходящих нам параметра - не более пяти конечных точек и функцию dcd_edpt_close(), которая в порте есть.
Стек TinyUSB рассчитан на компиляторы GCC, Clang и IAR, а в проекте используется ARM Compiler 5 (armcc) из состава Keil MDK. Этот компилятор не поддерживает часть встроенных функций GCC, поэтому в файлы стека внесены правки из таблицы 4. При обновлении стека, если используется 5-я версия компилятора Keil, их нужно повторить.

Таблица 4 - Правки в файлах стека TinyUSB

Файл Изменение Причина
src/common/tusb_compiler.h Для armcc (__ARMCC_VERSION < 6000000) макрос TU_ATTR_FALLTHROUGH определяется без вызова __has_attribute() В armcc нет __has_attribute(), препроцессор выдаёт ошибку #109
src/common/tusb_compiler.h TU_BSWAP16 записан сдвигами вместо __builtin_bswap16() В armcc нет функции __builtin_bswap16()


В ARM Compiler 6 (armclang) эти правки не нужны: он построен на Clang и поддерживает оба вызова.

Минимальная конфигурация стека на примере виртуального COM-порта приведена во фрагменте кода 2. Для другого класса меняются только строки CFG_TUD_* и настройка буферов.

Фрагмент кода 2 - Файл tusb_config.h примера CDC

#define CFG_TUSB_DEBUG          0
#define CFG_TUSB_OS                  OPT_OS_NONE
#define CFG_TUSB_MEM_ALIGN      __attribute__ ((aligned(4)))

#define CFG_TUD_ENABLED                 1
#define CFG_TUSB_RHPORT0_MODE   OPT_MODE_DEVICE
#define CFG_TUD_ENDPOINT0_SIZE    64
#define CFG_TUSB_SPEED                      OPT_MODE_FULL_SPEED

// Только CDC (COM-порт)
#define CFG_TUD_CDC             1
#define CFG_TUD_HID              0
#define CFG_TUD_MIDI            0
#define CFG_TUD_VENDOR    0

// Буферы для работы CDC
#define CFG_TUD_CDC_RX_BUFSIZE 64
#define CFG_TUD_CDC_TX_BUFSIZE 64

Инициализация и главный цикл (с небольшим уточнением, что функция обработки для каждого класса своя - для CDC, например, это cdc_task()) одинаковы для всех примеров. Тактирование ядра и USB настраивает сам порт в dcd_init().

Фрагмент кода 3 - Инициализация стека и главный цикл

int main(void)
{
    gpio_init();

    tusb_rhport_init_t dev_init = {.role = TUSB_ROLE_DEVICE, .speed = TUSB_SPEED_FULL};
    tusb_init(0, &dev_init);             // здесь же настраивается PLL

    SystemCoreClockUpdate();             // частота ядра изменилась в tusb_init
    SysTick_Config(SystemCoreClock / 1000);

    while (1)
    {
        tud_task();                      // обработка событий USB-стека
        cdc_task();                      // логика примера
    }
}
Важно отметить, что обработчик прерывания USB_IRQHandler() определён в файле порта. Если в проекте уже есть собственный обработчик с таким именем, его нужно убрать.

5. О подготовленном большом проекте для IDE Keil

Все примеры собраны в одном проекте USB_EXAMPLES.uvprojx, а пример выбирается целью (Target) в выпадающем списке на панели инструментов. Порт и стек общие для всех целей, у каждого примера в каталоге examples/<имя> лежат только его файлы: main.c, usb_descriptors.c, tusb_config.h и, где нужно, msc_disk.c и disk_image.c (для организации файловой системы - для примеров MSC). Результат сборки каждой цели попадает в свой каталог Objects/<цель>.

Таблица 5 - Описание проектов

Название Каталог Макрос  Устройство  VID:PID
CDC_ONLY examples/cdc-only EXAMPLE_CDC Виртуальный COM-порт CAFE:4001
HID_MOUSE examples/hid-only EXAMPLE_HID Мышь CAFE:4002
HID_KEYBOARD examples/hid-only EXAMPLE_HID, HID_MODE=2 Клавиатура CAFE:4003
MSC_ONLY examples/msc-only EXAMPLE_MSC Флэшка CAFE:4004
WEBUSB_ONLY examples/webusb-only EXAMPLE_WEBUSB_ONLY Устройство WebUSB CAFE:4005
MSC_HID examples/msc-hid EXAMPLE_MSC_HID Флэшка + клавиатура CAFE:4006
MSC_WEBUSB examples/msc-webusb EXAMPLE_MSC_WEBUSB Флэшка + WebUSB CAFE:4007

В проекте группы файлов общие для всего проекта. В группе Example лежат четыре файла-переходника (example_main.c, example_usb_descriptors.c, example_msc_disk.c, example_disk_image.c), которые подключают файл выбранного примера директивой #include. Первым в Include Paths каждой цели стоит каталог её примера, и стек находит нужный tusb_config.h.

Фрагмент кода 4 - Файл-переходник example_main.c

#if defined(EXAMPLE_CDC)
  #include "cdc-only/main.c"
#elif defined(EXAMPLE_HID)
  #include "hid-only/main.c"
#elif defined(EXAMPLE_MSC)
  #include "msc-only/main.c"
#elif defined(EXAMPLE_MSC_HID)
  #include "msc-hid/main.c"
#elif defined(EXAMPLE_WEBUSB_ONLY)
  #include "webusb-only/main.c"
#elif defined(EXAMPLE_MSC_WEBUSB)
  #include "msc-webusb/main.c"
#endif 

Исходники примеров правятся в каталогах examples/<имя>
Проект построен так, что примеры укладываются в ограничение бесплатной версии Keil MDK (32 Кбайт кода). Размер примера MSC_WEBUSB близок к границе, потому что во Flash-памяти лежит образ диска с веб-страницей.

6. Классы USB, используемые в примерах

При подключении хост запрашивает у устройства дескрипторы (этот процесс называется энумерацией) и по классу интерфейса выбирает драйвер. Для стандартных классов драйвер уже есть в Windows, поэтому устанавливать ничего не нужно. Краткое описание классов из примеров приведено в таблице 6.

Таблица 6 - Классы USB, используемые в примерах

Класс Что представляет  Как передаются данные  Драйвер на ПК 
CDC (Communication Device Class) Виртуальный COM-порт Поток байтов в обе стороны в рамках BULK-транзакций (две точки) плюс отдельная точка для уведомлений  Встроенный (usbser) 
HID (Human Interface Device) Мышь, клавиатура, джойстик Короткие отчёты по interrupt-точке, формат задаёт дескриптор отчёта Встроенный
MSC (Mass Storage Class) Накопитель (флэшка) Команды SCSI и блоки по 512 байт на точках BULK Встроенный
Vendor (с поддержкой WebUSB) Устройство со своим протоколом Кадры произвольного формата; в WebUSB на ПК с ним работает веб-страница в браузере WinUSB, Windows подключает сама по дескрипторам Microsoft OS

Одно физическое устройство может содержать несколько интерфейсов разных классов - такое устройство называется составным (composite). В TinyUSB для этого достаточно включить несколько классов в tusb_config.h и описать их интерфейсы в дескрипторе конфигурации - дополнительный код не нужен.

Подчеркнем главное ограничение при проектировании составного устройства на К1986ВЕ9х: помимо EP0 у контроллера есть только три точки, и в примерах каждая из них работает в одном направлении. Флэшка занимает две точки, CDC - три. Поэтому в примере MSC_WEBUSB интерфейс WebUSB получает только одну точку на передачу, а действия, совершенные на странице (включить/выключить светодиод), передаются микроконтроллером запросом на нулевую точку.

7. Разбор каждого примера: COM-порт, мышь, клавиатура, флэшка, WebUSB и составные устройства

Все примеры используют кнопки и светодиоды VD3, VD4 отладочной платы К1986ВЕ92FI, поэтому результат виден сразу после прошивки, без дополнительных программ - проект легко адаптировать под любую другую плату. Примеры рассмотрены в порядке усложнения - от одного класса к составным устройствам.

CDC_ONLY - виртуальный COM-порт

Микроконтроллер на плате определяется как COM-порт. Его можно открыть в любом терминале (PuTTY, Tera Term) на любой скорости - для виртуального порта она не имеет значения. Микроконтроллер пришлёт приветствие и будет выполнять команды из таблицы 7, а нажатия кнопок платы печатаются в терминале.

Таблица 7 - Команды примера CDC_ONLY

Команда Действие 
help Список команд 
led N on | off  Включить или выключить светодиод VD3 или VD4 (N = 3 или 4) 
btn  Какие кнопки нажаты сейчас 
uptime  Время с момента сброса 
speed  Передача 64 Кбайт на ПК и измерение скорости 
echo  Реализация эха (то, что отправляется с ПК, возвращается обратно) - используется в том числе при запуске стресс-теста 

Работа с портом в прошивке сводится к функциям tud_cdc_read_char() и tud_cdc_write_char(): стек сам буферизует данные и делит их на пакеты. 

В прошивке за вывод и приём отвечает несколько функций стека. Функция tud_cdc_connected() возвращает true, когда терминал открыл порт, поэтому ею удобно отсекать работу без слушателя. Функция tud_cdc_write_char() кладёт байт в буфер передачи размером 64 байта и возвращает false, если буфер полон. В этом случае tud_cdc_write_flush() просит стек отправить накопленное, а tud_task() даёт ему время это сделать. Приём устроен проще: tud_cdc_available() сообщает число принятых байтов, а tud_cdc_read_char() забирает очередной.

Фрагмент кода 5 - Вывод строки и приём команд в CDC_ONLY

// Если буфер CDC (64 байта) заполнен, ждём, обслуживая USB, пока хост его заберёт
static void put(const char* s)
{
    while (*s && tud_cdc_connected()) {
        if (tud_cdc_write_char(*s)) {
            s++;
        } else {
            tud_cdc_write_flush();
            tud_task();
        }
    }
    tud_cdc_write_flush();
}

// В главном цикле: терминал открыл порт - разбираем всё, что он прислал
if (tud_cdc_connected()) {
    while (tud_cdc_available()) {
        console_char((char)tud_cdc_read_char());
    }
}

HID_MOUSE и HID_KEYBOARD - мышь и клавиатура

Оба примера собираются из одних исходников examples/hid-only и отличаются макросом HID_MODE: в цели HID_KEYBOARD в Define добавлено HID_MODE=2. Режим (мышь или клавиатура) дополнительно задан в PID, поэтому Windows считает мышь и клавиатуру разными устройствами. Назначение кнопок приведено в таблице 8.

Таблица 8 - Назначение кнопок в примерах HID

 Кнопка HID_MOUSE  HID_KEYBOARD 
SELECT  Левая кнопка мыши  Enter 
LEFT, RIGHT, UP, DOWN  Курсор движется, пока кнопка удержана (сделано так, что скорость растет с временем удержания)  Стрелки 
UP + DOWN Курсор рисует окружность (в Paint с зажатой левой кнопкой) Микроконтроллер сам набирает текст "Hello from Milandr!" 

Отправка события занимает одну строку (устройство периодически отправляет посылки в виде отчетов):

Фрагмент кода 6 - Отправка отчёта мыши

if ((changed || timer) && tud_hid_ready()) {
    tud_hid_mouse_report(0, buttons, dx, dy, 0, 0);   // кнопки, смещение X и Y
}

MSC_ONLY - флэшка

Микроконтроллер на плате определяется как съёмный диск с меткой "Milandr MSC". Диск находится в ОЗУ (16 секторов по 512 байт, файловая система FAT12) и содержит два файла: README.TXT с описанием и LED.TXT. Если записать в LED.TXT цифру "1" и сохранить файл - загорится светодиод VD3; запись "0" его выключит. Также легко передавать устройству настройки или считывать из него журнал измерений без какой-либо программы на ПК.

От приложения стек ждёт несколько функций обратного вызова: размер диска, чтение и запись блоков. Все команды SCSI стек обрабатывает сам.

Фрагмент кода 7 - Чтение блока диска

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_BLOCK_SIZE + offset + bufsize > sizeof(msc_disk)) return -1;

  memcpy(buffer, msc_disk[lba] + offset, bufsize);
  return (int32_t)bufsize;
}
Для реального устройства в этих функциях достаточно заменить memcpy() на чтение и запись внешней Flash-памяти или SD-карты.
Диск расположен в ОЗУ, поэтому после сброса платы его содержимое восстанавливается. Объём в 8 Кбайт выбран не случайно: диск меньшего размера Windows не подключает.

WEBUSB_ONLY - управление платой из браузера

Технология WebUSB позволяет веб-странице обмениваться данными с USB-устройством напрямую, без драйверов (на практике используется драйвер WinUSB, но он подключается автоматически посредством энумерации, информация о чем раскрыта далее по тексту) и без прав администратора. Поддерживается браузерами на базе Chromium (Chrome, Edge и т.д - в Firefox и Safari WebUSB не поддерживается). Страница примера предоставляет следующий функционал:

• Светодиоды VD3 и VD4 включаются кнопками на странице; 
• Нажатая на плате кнопка подсвечивается на экране;
• Выводится код АЦП встроенного датчика температуры и график показаний за последние 10 секунд - достаточно коснуться микроконтроллера пальцем, чтобы увидеть изменение;
• Встроена игра "Змейка", которой управляют кнопки платы.

Устройство содержит Vendor-интерфейс с двумя точками типа BULK: EP1 OUT для команд и EP2 IN для данных. Протокол обмена приведён в таблице 9, все числа передаются младшим байтом вперёд.

Таблица 9 - Протокол обмена примера WebUSB

Направление Кадр  Когда 
Плата > Страница 0x01, состояние кнопок, светодиодов, АЦП (2 байта), количество мс с запуска (4 байта)  Каждые 20 мс и сразу при нажатии кнопки 
Страница > Плата  0x01, управление светодиодами По клику на странице


В прошивке обмен со страницей занимает две короткие функции. Кадр состояния отправляет tud_vendor_write() с последующим tud_vendor_write_flush(); перед ней tud_vendor_write_available() проверяет, есть ли в буфере место. Если страница не читает данные, кадр просто пропускается. Команды принимает пара tud_vendor_available() и tud_vendor_read(): они работают так же, как функции чтения CDC в первом примере.

Фрагмент кода 8 - Обмен кадрами со страницей в WEBUSB_ONLY

static void send_state(void)
{
    uint16_t adc = adc_read();
    uint32_t ms  = ms_ticks;
    uint8_t  frame[9] = {
        MSG_STATE, buttons_state, leds_state,
        (uint8_t)adc, (uint8_t)(adc >> 8),                                         
        (uint8_t)ms, (uint8_t)(ms >> 8), (uint8_t)(ms >> 16), (uint8_t)(ms >> 24)  // Время с момента запуска, 4 байта
    };

    if (tud_vendor_write_available() >= sizeof(frame)) {   
        tud_vendor_write(frame, sizeof(frame));
        tud_vendor_write_flush();
    }
}

static void receive_commands(void)
{
    uint8_t cmd[2];

    while (tud_vendor_available() >= sizeof(cmd)) {
        tud_vendor_read(cmd, sizeof(cmd));
        if (cmd[0] == MSG_LEDS) leds_set(cmd[1]);
    }
}

Драйвер WinUSB ОС Windows подключает сама. Для этого микроконтроллер отвечает на запросы Microsoft OS 1.0: идентификатор совместимости WinUSB и GUID интерфейса (специальная последовательность символов, которую можно видеть в файле usb_descriptors.c примера). Страница запускается прямо с диска, как обычный файл. На уровне файла app.js (файл JavaScript, работающий в связке со страницей .html - именно в этом файле реализован низкий уровень всех функций со страницы (уровень транзакций USB)) контрольно проверяется VID устройства для подключения, то есть если такое устройство есть в системе, то оно появится в доступных, когда будет нажата кнопка "Подключить плату".

Общий вид страницы представлен на рисунке 2.


Рисунок 2 - Общий вид страницы браузера примера с реализацией WebUSB

MSC_HID - флэшка и клавиатура в одном устройстве

Первый из составных примеров: микроконтроллер одновременно является флэшкой и клавиатурой. На диске лежит файл TEXT.TXT. Достаточно отредактировать и сохранить его, поставить курсор в любое текстовое поле на ПК и нажать кнопку SELECT - микроконтроллер "наберёт" текст файла сама. Во время печати диск продолжает работать.

Интерфейсы распределены по точкам так: флэшка - EP1 OUT и EP2 IN, клавиатура - EP3 IN. Код приложения при этом просто объединяет код двух предыдущих примеров.

Основа примера - функция печати текста. Для каждого символа она отправляет два отчёта: "клавиша нажата" и "клавиша отпущена". Функция tud_hid_ready() сообщает, что предыдущий отчёт уже ушёл и точка свободна, а tud_hid_keyboard_report() ставит в очередь новый. Пока точка занята, вызывается tud_task(), поэтому диск продолжает работать во время печати. Таблицу соответствия символов и кодов клавиш содержит сам стек: это макрос HID_ASCII_TO_KEYCODE.

Фрагмент кода 9 - Печать текста клавиатурой в MSC_HID

static void type_text(const char* text, int len)
{
    static const uint8_t ascii_to_key[128][2] = { HID_ASCII_TO_KEYCODE };   // {нужен Shift, код клавиши}

    for (int i = 0; i < len; i++) {
        uint8_t c = (uint8_t)text[i];
        if (c >= 128 || c == '\r' || ascii_to_key[c][1] == 0) continue;     // символа нет на клавиатуре

        uint8_t keycodes[6] = { ascii_to_key[c][1] };
        uint8_t shift       = ascii_to_key[c][0] ? KEYBOARD_MODIFIER_LEFTSHIFT : 0;

        for (int press = 1; press >= 0; press--) {         // сначала "нажата", потом "отпущена"
            while (!tud_hid_ready()) tud_task();
            tud_hid_keyboard_report(0, press ? shift : 0, press ? keycodes : NULL);
            wait_ms(KEY_PERIOD_MS);
        }
    }
}

MSC_WEBUSB - флэшка с веб-страницей управления платой

В качестве одного из примеров мы решили создать некоторое законченное составное изделие. Микроконтроллер на ПК определяется как диск "Milandr WEB" с файлами index.htm, app.js и README.TXT. После открытия index.htm прямо с диска в любом браузере на базе Chromium и нажатия кнопки "Подключить плату" страница по WebUSB начинает управлять той же платой, с которой она загружена. Как и описывалось ранее, ни драйверов, ни сервера, ни интернета не требуется, но только здесь устройство носит свой интерфейс управления сразу с собой.

На рисунке 3 показана структура работы примера: через интерфейс MSC браузер получает страницу, а через Vendor-интерфейс эта страница обменивается данными с микроконтроллером.


Рисунок 3 - Структура примера MSC_WEBUSB

Составное устройство собирается в usb_descriptors.c из готовых макросов стека. TUD_CONFIG_DESCRIPTOR() открывает конфигурацию, TUD_MSC_DESCRIPTOR() добавляет флэшку, а для WebUSB описан собственный Vendor-интерфейс с единственной точкой IN. Номера интерфейсов и точек заданы константами, поэтому видно, как делятся ресурсы контроллера: EP1 и EP2 отданы флэшке, EP3 - WebUSB.

Фрагмент кода 10 - Дескриптор конфигурации MSC_WEBUSB

static uint8_t const desc_configuration[] = {
    TUD_CONFIG_DESCRIPTOR(1, ITF_NUM_TOTAL, 0, CONFIG_TOTAL_LEN, 0x00, 100),

    // Флэшка: интерфейс 0, EP1 OUT и EP2 IN
    TUD_MSC_DESCRIPTOR(ITF_NUM_MSC, 0, EPNUM_MSC_OUT, EPNUM_MSC_IN, 64),

    // WebUSB: интерфейс 1, EP3 IN (команды идут через EP0)
    VENDOR_IN_ONLY_DESCRIPTOR(ITF_NUM_VENDOR, EPNUM_VENDOR_IN, 64),
};
Особенности реализации:
• Диск хранится во Flash-памяти микроконтроллера как готовый образ FAT12 объёмом около 15 Кбайт и доступен только для чтения, ОЗУ под него не нужно;
• Образ страницы для Flash-памяти создаёт скрипт tools/make_disk.py из каталога web, при этом файлы страницы оптимизируются (удаляются комментарии и отступы);
• Свободных точек на приём у контроллера не остается, поэтому страница передаёт команду светодиодам Vendor-запросом на нулевую точку (bRequest = 0x10, в wValue - маска светодиодов).


Запросы к нулевой точке обрабатывает функция обратного вызова tud_vendor_control_xfer_cb(). Стек вызывает её для каждого Vendor-запроса хоста. Через неё проходят и команда светодиодам, и ответы на запросы Windows о драйвере WinUSB. Ответ отправляет tud_control_xfer() (с данными) или tud_control_status() (подтверждение без данных); возврат false заставляет стек ответить STALL.

Фрагмент кода 11 - Vendor-запросы хоста в MSC_WEBUSB

bool tud_vendor_control_xfer_cb(uint8_t rhport, uint8_t stage, tusb_control_request_t const *request) {
  if (stage != CONTROL_STAGE_SETUP) return true;     // нужен только этап SETUP

  if (request->bmRequestType_bit.type != TUSB_REQ_TYPE_VENDOR) return false;

  if (request->bRequest == REQ_SET_LEDS) {           // страница управляет светодиодами
    leds_set((uint8_t) request->wValue);
    return tud_control_status(rhport, request);
  }

  if (request->bRequest != MS_VENDOR_CODE) return false;      // неизвестный запрос: STALL

  if (request->wIndex == MS_REQ_COMPAT_ID) {         // подключение WinUSB
    return tud_control_xfer(rhport, request, (void *) desc_ms_compat_id, sizeof(desc_ms_compat_id));
  }
  if (request->wIndex == MS_REQ_PROPERTIES) {        // GUID интерфейса
    return tud_control_xfer(rhport, request, (void *) desc_ms_properties, sizeof(desc_ms_properties));
  }
  return false;
}

Чтобы изменить страницу, нужно отредактировать файлы в examples/msc-webusb/web, выполнить python tools/make_disk.py и пересобрать пример. Имена файлов на диске ограничены форматом 8.3 (формат коротких имен - используется для оптимизации места), поэтому index.html лежит на диске как index.htm.

Подход, используемый в примере, хорошо подходит для реальных изделий: например, конфигуратор датчика, сервисное меню блока, панель диагностики или обновление инструкции пользователя поставляются внутри самого устройства.

8. Проверка примеров тестами энумерации и нагрузки

Чтобы убедиться в устойчивости порта (в случае любых изменений), к примерам прилагаются скрипты на Python в каталоге tools. Они проверяют два типичных источника ошибок USB-устройств: повторное подключение и длительный обмен данными.

• Тест энумерации enum_test.py (требуются права администратора) программно отключает и снова включает USB-порт командой IOCTL_USB_HUB_CYCLE_PORT и проверяет, что устройство восстановило работу: все его узлы запущены без ошибок, а у дисков читается том. Для каждого цикла печатается время подключения или причина отказа.

• Нагрузочные тесты: cdc_stress.py гоняет поток данных и эхо через COM-порт и переоткрывает порт (нужен пакет pyserial), msc_stress.py читает диск случайными блоками (режим raw) или создаёт, перезаписывает и удаляет файлы с проверкой содержимого (режим files).

Таблица 10 - Проверка примеров

Пример Тест энумерации  Нагрузочный тест 
CDC_ONLY python enum_test.py cdc-only 250 0.3 python cdc_stress.py COMx all --minutes 15
HID_MOUSE python enum_test.py hid-mouse 250 0.3 Вручную движением курсора и отрисовкой окружности при помощи сочетания "UP + DOWN"
HID_KEYBOARD python enum_test.py hid-keyboard 250 0.3 Вручную движением курсора в текстовом файле и набором текста при помощи сочетания "UP + DOWN"
MSC_ONLY python enum_test.py msc-only 250 0.3 python msc_stress.py raw \\.\E: --minutes 10, затем files E:\
WEBUSB_ONLY python enum_test.py webusb-only 250 0.3 Вручную открыть страницу в браузере и протестировать функционал со светодиодами, кнопками и АЦП
MSC_HID python enum_test.py msc-hid 250 0.3 python msc_stress.py raw \\.\E: --minutes 10, затем files E:\
Здесь же вручную нажать "SELECT" и проконтролировать набор текста из файла TEXT.TXT
MSC_WEBUSB python enum_test.py msc-webusb 250 0.3 python msc_stress.py raw \\.\E: --minutes 10
Здесь же вручную открыть страницу в браузере и протестировать функционал со светодиодами, кнопками и АЦП

Аргументы enum_test.py: имя примера, число циклов (по умолчанию 100, в примерах используем 250) и пауза между циклами в секундах. Букву диска и номер COM-порта необходимо подставить свои.

Здесь лишь отметим, что Windows запоминает драйвер и дескрипторы устройства по связке VID/PID. Если микроконтроллер уже подключался с другой прошивкой и теми же VID/PID, то во избежание разного рода коллизий, старое устройство следует удалить в диспетчере устройств вместе с драйвером. Именно поэтому у каждого примера свой PID (VID один).

9. Что учесть при переходе от примера к собственному устройству

Собственное USB-устройство удобнее всего делать на основе ближайшего примера с реализацией нужного класса (классов). Порт и стек при этом не меняются, изменяются только файлы приложения:

1. В tusb_config.h включить нужные классы и подобрать размеры буферов (стандартно достаточно 64 байт).

2. В usb_descriptors.c задать VID, PID, строки производителя, названия и серийного номера, описать интерфейсы и номера конечных точек.

3. Реализовать функции обратного вызова класса: например, для флэшки заменить диск в ОЗУ на внешнюю память.

4. В главном цикле регулярно вызывать tud_task() и не допускать длительных блокирующих задержек.

5. Проверить устройство тестом энумерации и нагрузочными тестами.

При этом нужно учитывать ограничения из таблицы 11.

Таблица 11 - Ограничения при разработке устройства

Ограничение Что оно значит 
Идентификатор производителя VID 0xCAFE является тестовым Для серийного изделия нужен собственный VID или PID, выделенный владельцем VID
Три конечные точки помимо EP0 Реализация составного устройства чаще всего будет ограничена лишь двумя классами, для большего числа требуется больше аппаратных конечных точек
Бесплатная версия IDE Keil    Стек с одним классом занимает лишь часть лимита, но образ диска или большая логика потребуют полной лицензии или другого компилятора   
Windows кэширует драйвер по VID/PID    При изменении набора интерфейсов следует менять PID или bcdDevice
Подчеркнем, что порт реализует только режим устройства. Изохронные передачи (классы Audio и Video) и выход из режима пониженного энергопотребления по инициативе устройства (remote wakeup) в текущей версии не поддерживаются.

Заключение

Порт TinyUSB для микроконтроллеров К1986ВЕ9х и К1986ВЕ1х занимает один файл и даёт микроконтроллеру доступ к стандартным классам USB из популярного открытого стека. Семь примеров показывают, что COM-порт, мышь, клавиатура, флэшка и управление из браузера укладываются в несколько сотен строк кода приложения, а составное устройство собирается из них как из кубиков. Работа примеров проверена циклами переподключения и длительной нагрузкой.

Проект целиком (порт, стек TinyUSB 0.21.0, примеры и скрипты проверки) доступен для загрузки в разделе "Файлы для скачивания" после статьи.

Обратите внимание, что порт TinyUSB является тестовым. Несмотря на тщательное тестирование, не исключено, что в каких-то условиях или на каком-то оборудовании что-то будет работать не так. В этом случае просим описать проблему (микроконтроллер, цель проекта, операционную систему, шаги воспроизведения) и направить описание в отдел технической поддержки по электронной почте support@milandr.ru.

Контактная информация

Сайт:https://support.milandr.ru
E-mail:support@milandr.ru
Телефон: +7 495 221-13-55