Еще более низкий уровень (avr-vusb)
USB на регистрах: STM32L1 / STM32F1
USB на регистрах: bulk endpoint на примере Mass Storage
USB на регистрах: interrupt endpoint на примере HID
USB на регистрах: isochronous endpoint на примере Audio device


Вот мы познакомились со всеми базовыми типами конечных точек, пришло время разработать какое-нибудь полезное устройство. Для примера пусть это будет программатор-отладчик STM-ок, работающий через стандартный UART bootloader.


Введение


Допустим, мы разрабатываем какое-то устройство. Хорошо если оно сводится к стандартному. Ну, например, хитрый вольтметр с управлением по COM-порту: реализовали класс CDC, а дальше накручиваем вокруг него протокол. Но что если для нас это слишком просто? Вольтметр вольтметром, но у контроллера остается свободной еще куча памяти и куча ножек, их тоже хочется пустить в дело. Пусть вольтметр, скажем, служит заодно переходником USB-COM для своих менее продвинутых собратьев, которые до сих пор считают RS-232 вершиной коммуникационных технологий. А еще пусть изображает флешку, на которую положим всю документацию, вот!


Это был абстрактный пример зачем мы можем захотеть объединить в одном корпусе несколько независимых устройств. Для практической же реализации я предлагаю кое-что другое. Как известно, контроллеры STM (да и многие другие) имеют самозагрузчик (bootloader), который позволяет прошить их вообще без программатора — через UART. Правда, процесс это непростой. Надо подключить переходник, переключить Boot0, дернуть ресет, закрыть программу просмотра COM-порта, запустить прошивальщик. А потом проделать то же самое в обратном порядке. Для разовой задачи такое количество телодвижений еще приемлемо, но для повседневного использования — нет. А что делает программист, когда ему надоедает раз за разом повторять рутинную работу? Правильно, он ее автоматизирует! Автоматизация будет заключаться в том, чтобы прошить один контроллер (в моем примере будет stm32f103, но то же самое можно сделать и на stm32l151) чтобы он отображался в системе как COM-порт, а еще мог по команде с компьютера дергать Boot0, Reset и реле размыкания линий данных USB. Последнее может быть полезно для контроллеров, которые умеют прошиваться не только через UART, но и через USB: на время прошивки линии D+, D- разрываются, а потом подключаются обратно. Также имеет смысл добавить второй COM-порт для отладки. Физически они оба будут работать через один и тот же USART1, но на время прошивки не будет необходимости закрывать окно терминала. Ну и раз уж я поставил задачу продемонстрировать составное устройство, помимо двух CDC будет еще и MSD, на котором разместится бинарник прошивки самого программатора, пример makefile и всякая прочая информация.


TL;DR. Устройство будет отображаться в системе как CDC для отладки (назовем его DBG), CDC для программирования (назовем его STFLASH, поскольку для программирования STM’ок используется именно эта утилита) и MSD для всякой документации.


CDC


Если бы два наших CDC-устройства никак не пересекались — висели бы на разных физических UART, или были вообще виртуальными, рассказывать было бы вообще не о чем. Но нам нужно, чтобы они не конфликтовали при работе с одним и тем же USART1. Сделать это довольно просто: пусть при настройке скорости STFLASH, он захватывает весь UART, а по специальной команде (или по таймауту) переключает обратно на DBG. Кстати о командах. Нам ведь нужно еще и Boot0 с ресетом дергать. Можно было бы завести для этого еще одно под-устройство, но для взаимодействия с ним пришлось бы еще и свою программу писать. Нет уж, пусть управление ножками идет через тот же STFLASH, а отличаться от данных будет по скорости: если выставлено 50 бод, то переходим в режим управления ножками (все равно программаторы на такой медленной не работают), а если любая другая, то обмен данными. Ну и управление пусть осуществляется стандартной для CDC посылкой текстовых символов. 'B' будет означать включить Boot0, 'b' — выключить. Аналогично 'R' и 'r' для ресета и 'U', 'u' для USB. Ну а выход из режима программирования по 'z'.


Таким образом алгоритм программирования, оформленный в виде makefile, будет следующим:


prog:   $(frmname).bin
    stty -F /dev/tty_STFLASH_0 300
    stty -F /dev/tty_STFLASH_0 50
    echo 'RBU' > /dev/tty_STFLASH_0
    echo 'rBU' > /dev/tty_STFLASH_0
    sleep 1
    stm32flash /dev/tty_STFLASH_0 -w $(frmname).bin
    stty -F /dev/tty_STFLASH_0 50
    echo 'RbU' > /dev/tty_STFLASH_0
    sleep 1
    echo 'rbuz' > /dev/tty_STFLASH_0

Сначала выставляем "какую-нибудь" скорость и тут же 50 бод, чтобы наш "программатор" точно получил запрос на изменение скорости, и, как результат, перешел в режим программирования. То есть отцепил USART1 от DBG и привязал к STFLASH. Далее переключаем Boot0 и USBR во "включенное" состояние (то, которое для прошивки) и дергаем Reset. Запускаем stm32flash. Снова переключаемся в режим управления ножками и ресетим контроллер уже с Boot0 и USB, выставленными для обычной работы. Ну и финальная посылка 'rbuz' чтобы отпустить ресет и выйти обратно в режим отладки.


Отдельно стоит обратить внимание, что STFLASH отображается в системе именно как /dev/tty_STFLASH_0, а не как безликий /dev/ttyACM100500. Чтобы этого достичь, я прописал у данного интерфейса поле iFunction. Это номер строки, которую хост запрашивает наравне с iManufacturer, iProduct, iSerial. Ну а сама строка содержит, как и следовало ожидать, u"STFLASH" (напоминаю, строки в USB имеют кодировку UTF-16). Аналогичным образом описан интерфейс DBG. Теперь в выводе lsusb -v эти строки будут отображаться.


Осталось заставить систему эти строки применить для именования символьных ссылок. Для этого воспользуемся механизмом udev и напишем следующее правило (создать файл вроде /etc/udev/rules.d/98-usbserial.rules):


SUBSYSTEM=="tty", ATTRS{manufacturer}=="COKPOWEHEU" ENV{CONNECTED_COKP}="yes"
ENV{CONNECTED_COKP}=="yes", SUBSYSTEM=="tty", ATTRS{interface}=="?*", PROGRAM="/bin/bash -c \"ls /dev | grep tty_$attr{interface}_ | wc -l \"", SYMLINK+="tty_$attr{interface}_%c"

Здесь написано буквально следующее: если подключено устройство, классифицируемое как tty (COM-порт), и у него строка iManufacturer равна "COKPOWEHEU", то создать переменную CONNECTED_COKP и присвоить ей значение "yes". Ограничение на производителя я ввел чтобы не создавались ссылки на чужие устройства. Дело в том, что у них iFunction не прописан почти никогда, так что пользы именно от этого правила не будет. Тут нужно действовать по-другому.


Во второй строчке проверяется значение созданной переменной, еще раз подсистема tty и наличие поля interface (я толком не понял, зачем это делать, но без проверки его не получается потом прочитать). После чего запускается скрипт, считающий количество уже созданных ссылок с таким же именем, и на основании подсчета формируется очередная ссылка. Соответственно в случае нашего "программатора" получаются ссылки /dev/tty_STFLASH_0 и /dev/tty_DBG_0.


Сразу должен предупредить, что в тонкостях udev я не разбираюсь, и точно объяснить, почему правило должно состоять именно из двух строк не смогу (вроде бы первая обращается к устройству в целом, а вторая — к его внутренностям). А также почему пришлось писать свой скрипт вместо специально для этого предназначенного формата %n, который тоже по идее считает ссылки. Что самое обидное, для одних устройств лучше работает скрипт, для других %n.


Внимание, грабли! Еще одна тонкость, связанная с stm32flash: для программирования UART выставляется в режим 57600 8E1. Требуется реализовать управление четностью!


Как результат, теперь можно в одном окне запустить screen, а в другом запускать make prog, и они не будут друг другу мешать.


Ну и раз уж я упомянул про способ именования COM-портов от известных производителей, стоит привести пример соответствующего правила:


SUBSYSTEM=="tty", ATTRS{idVendor}=="0403", ATTRS{idProduct}=="6001", PROGRAM="/bin/bash -c \"ls /dev | grep tty_ft232r_ | wc -l \"", SYMLINK+="tty_ft232r_%c"
SUBSYSTEM=="tty", ATTRS{idVendor}=="0403", ATTRS{idProduct}=="6011", PROGRAM="/bin/bash -c \"ls /dev | grep tty_ft4232_ | wc -l \"", SYMLINK+="tty_ft4232_%n"
SUBSYSTEM=="tty", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="7523", PROGRAM="/bin/bash -c \"ls /dev | grep tty_hl340_ | wc -l \"", SYMLINK+="tty_hl340_%c"
SUBSYSTEM=="tty", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", PROGRAM="/bin/bash -c \"ls /dev | grep tty_cp210x_ | wc -l \"", SYMLINK+="tty_cp210x_%c"
SUBSYSTEM=="tty", ATTRS{idVendor}=="067b", ATTRS{idProduct}=="2303", PROGRAM="/bin/bash -c \"ls /dev | grep tty_pl2303_ | wc -l \"", SYMLINK+="tty_pl2303_%c"

Здесь выбор имени осуществляется только на основании VID:PID.


MSD


По большому счету, для задачи программирования контроллеров тот ущербный MSD, который поместится в stm32f103, не нужен. Вот если бы там хватало памяти хранить всю прошивку (а ведь это MSD, прошивку сначала придется целиком собрать, отсортировать сектора, и только потом собственно прошивать), да написать алгоритм ее записи… Но чего нет, того нет. Поэтому пусть на "флешке" хранится прошивка самого "программатора" (мы за свободное использование!), пример простейшего makefile, пример правила для udev, перечень ножек и, скажем, драйвер для винды. На самом деле не особо представляю зачем он там нужен, если у меня все скрипты для линукса… Но вдруг кому-то да пригодится, жалко что ли. Очевидно, формировать под это дело настоящий образ fat16 нет желания, да и возможности (у нас флеш-памяти столько нет), поэтому обратимся к рассмотренному ранее virfat. По большому счету, ничего более интересного тут нет, разве что firmware.bin будет читаться прямо из флеш-памяти "программатора", с адреса 0x08000000 и размером 20 кБ. К сожалению, узнать размер прошивки в компил-тайме не получилось, поэтому пришлось взять с запасом. На самом деле можно было и все 64 кБ отобразить, но ведь чем больше размер, тем больше времени уйдет на чтение.


В качестве виндовго драйвера у меня нашелся inf от LUFA (это библиотека для AVR). На самом деле странно, что для стандартного CDC в старых виндах (я проверял на winXP, win7) не подхватывается какой-нибудь существующий драйвер, ну да ладно.


Еще стоит обратить внимание на переносы строк в текстовых файлах. Там, где они предназначены для чтения человеком, я поставил '\r\n', чтобы оно корректно отображалось и в винде, и в линуксе. А там, где для чтения машиной (правило udev, makefile) — обычный '\n'. Они в любом случае Linux-only, поэтому без разницы как отображаются в других системах.


Составное устройство


И вот настало наконец время все эти три под-устройства объединить в одно. Для этого достаточно в Configuration Descriptor'е просто их прописать один за другим. А чтобы система могла распознать, какие фрагменты дескриптора относятся к одному устройству, а какие к другому, перед каждым из них добавляется так называемый Interface Assotiation Descriptor, IAD. Покажу его на примере CDC:


ARRLEN1( //IAD
  bLENGTH,
  USB_DESCR_IAD, //0x0B //bDescriptorType
  ifnum( interface_tty ), //bFirstInterface
  ifcnt( interface_tty ), //bInterfaceCount
  DEVCLASS_CDC, // bInterfaceClass: 
  CDCSUBCLASS_ACM, // bInterfaceSubClass: 
  CDCPROTOCOL_UNDEF, // bInterfaceProtocol: 
  STR_TTY,//0x00, // iFuncion
)

Сначала идут два стандартных поля, длина дескриптора и его тип. Потом номер первого интерфейса, относящегося к под-устройству, и их количество. Очевидно, интерфейсы должны идти подряд. Следующие три поля отвечают за идентификацию устройства: Class, Subclass, Protocol. И наконец, строковое описание. Честно говоря, я не особо понял зачем все это описывать здесь, если оно потом дублируется в описании самого под-устройства. Но, видимо, не все под-устройства так умеют.


Также, поскольку теперь нельзя классифицировать устройство в целом, DeviceDescriptor тоже придется доработать. В качестве Class, Subclass, Protocol выставляем "составное устройство", 0xEF, 0x02, 0x01.


Результирующий дескриптор будет выглядеть примерно так (содержимое под-устройств поубирал для краткости):


static const uint8_t USB_ConfigDescriptor[] = {
  ARRLEN34(
  ARRLEN1(
    bLENGTH, // bLength: Configuration Descriptor size
    USB_DESCR_CONFIG,    //bDescriptorType: Configuration
    wTOTALLENGTH, //wTotalLength
    interface_count, // bNumInterfaces
    1, // bConfigurationValue: Configuration value
    0, // iConfiguration: Index of string descriptor describing the configuration
    0x80, // bmAttributes: bus powered
    0x32, // MaxPower 100 mA
  )

//ttyACM0 (interface 0, 1) - TTY
  ARRLEN1( //IAD
    bLENGTH,
    USB_DESCR_IAD, //bDescriptorType
    ifnum( interface_tty ), //bFirstInterface
    ifcnt( interface_tty ), //bInterfaceCount
    DEVCLASS_CDC, // bInterfaceClass: 
    CDCSUBCLASS_ACM, // bInterfaceSubClass: 
    CDCPROTOCOL_UNDEF, // bInterfaceProtocol: 
    STR_TTY, // iFuncion
  )
    ARRLEN1(//CDC1 descriptor
      bLENGTH, // bLength
      USB_DESCR_INTERFACE, // bDescriptorType
      ifnum( interface_tty ), // bInterfaceNumber
      0, // bAlternateSetting
      1, // bNumEndpoints
      DEVCLASS_CDC, // bInterfaceClass: 
      CDCSUBCLASS_ACM, // bInterfaceSubClass: 
      CDCPROTOCOL_UNDEF, // bInterfaceProtocol: 
      STR_TTY, // iInterface
    )
    ARRLEN1(...) //CDC1 Header
    ARRLEN1(...) //CDC1 Call mamagement
    ARRLEN1(...) //CDC1 ACM settings
    ARRLEN1(...) //CDC1 Union
    ARRLEN1(...) //CDC1 interrupt endpoint IN
    ARRLEN1(...) //CDC1 data interface
    ARRLEN1(...) //CDC1 data endpoint OUT
    ARRLEN1(...) //CDC1 data endpoint IN

// ttyACM1 (interfaces 2, 3) - PROGR
  ARRLEN1( //IAD
    bLENGTH,
    USB_DESCR_IAD, //IAD descriptor
    ifnum( interface_progr ), //bFirstInterface
    ifcnt( interface_progr ), //bInterfaceCount
    DEVCLASS_CDC, // bInterfaceClass: 
    CDCSUBCLASS_ACM, // bInterfaceSubClass: 
    CDCPROTOCOL_UNDEF, // bInterfaceProtocol: 
    STR_PROGR, // iFuncion
  )
    ARRLEN1(...) //CDC2 descriptor
      bLENGTH, // bLength
      USB_DESCR_INTERFACE, // bDescriptorType
      ifnum( interface_progr ),// bInterfaceNumber
      0, // bAlternateSetting
      1, // bNumEndpoints
      DEVCLASS_CDC, // bInterfaceClass:
      CDCSUBCLASS_ACM, // bInterfaceSubClass:
      CDCPROTOCOL_UNDEF, // bInterfaceProtocol:
      STR_PROGR, // iInterface
    )
    ARRLEN1(...) //CDC2 Header
    ARRLEN1(...) //CDC2 Call mamagement
    ARRLEN1(...) //CDC2 ACM settings
    ARRLEN1(...) //CDC2 Union
    ARRLEN1(...) //CDC2 interrupt endpoint IN
    ARRLEN1(...) //CDC2 data interface
    ARRLEN1(...) //CDC2 data endpoint OUT
    ARRLEN1(...) //CDC2 data endpoint IN

//MSD
  ARRLEN1( //IAD
    bLENGTH,
    USB_DESCR_IAD, //IAD descriptor
    ifnum( interface_msd ), //bFirstInterface
    ifcnt( interface_msd ), //bInterfaceCount
    MSDCLASS_MSD, // bInterfaceClass:
    MSDSUBCLASS_SCSI, // bInterfaceSubClass:
    MSDPROTOCOL_BULKONLY, // bInterfaceProtocol:
    STR_MSD, // iFuncion
  )
    ARRLEN1( // MSD descriptor
      bLENGTH, //bLength
      USB_DESCR_INTERFACE, //bDescriptorType
      ifnum( interface_msd ),// bInterfaceNumber
      0, // bAlternateSetting
      2, // bNumEndpoints
      MSDCLASS_MSD, // bInterfaceClass:
      MSDSUBCLASS_SCSI, // bInterfaceSubClass:
      MSDPROTOCOL_BULKONLY, // bInterfaceProtocol:
      STR_MSD, // iInterface
    )
    ARRLEN1(...) //MSD endpoint IN
    ARRLEN1(...) //MSD endpoint OUT

  )
};

Внимание, грабли! Чего мне в своей библиотеке реализовать не удалось, так это автоматической нумерации интерфейсов и конечных точек. Приходится прописывать вручную. Внимание, грабли! И, конечно, не забывать, чтобы суммарное количество интерфейсов, записанное в начале Configuration Descriptor, совпадало с реальным. Почему-то именно на эти грабли я наступаю особенно часто.


После этого устройство уже способно определиться в системе, но для полноценной работы еще надо настроить конечные точки и все остальное. Из соображений масштабируемости я разделил файл описания устройства в целом (usb_class.c) и реализацию каждого под-устройства (programmer.c, usb_class_msd.c) и прописал в исходнике каждого под-устройства функции вида


char *_ep0_in(config_pack_t *req, void **data, uint16_t *size);
char *_ep0_out(config_pack_t *req, uint16_t offset, uint16_t rx_size);
void *_init();
void *_poll();

А из стандартных функций по очереди их вызываю:


char usb_class_ep0_in(config_pack_t *req, void **data, uint16_t *size){
  if( programmer_ep0_in( req, data, size ) )return 1;
  if( msd_ep0_in( req, data, size ) )return 1;
  return 0;
}

char usb_class_ep0_out(config_pack_t *req, uint16_t offset, uint16_t rx_size){
  if( programmer_ep0_out( req, offset, rx_size ) )return 1;
  if( msd_ep0_out( req, offset, rx_size ) )return 1;
  return 0;
}

void usb_class_init(){
  programmer_init();
  msd_init();
}

void usb_class_poll(){
  programmer_poll();
  msd_poll();
}

А что на windows


Коротко говоря — не знаю. То есть устройство там отображается, установить драйвер с флешки получается (правда, он неподписанный, но уж какой есть), COM-порты видны. Правда, только как COM6, COM7 или что-то в этом роде. Способа присвоить им читаемые имена я не нашел. Для прошивки STM, насколько я видел, тот же stm32flash вполне существует в виде экзешника. Вроде бы существуют и другие прошивальщики, но их я не искал. Ну и еще вместо stty придется использовать MODE.COM COM6 BAUD=50 > NUL. В общем, что по этой теме знал, рассказал, а дальше лезть не буду чтобы никого не запутать.


Как обычно, если хоть чуть-чуть ошибиться в дескрипторе (у меня это было с количеством интерфейсов и когда проверял обязательно ли для MSD добавлять персональный IAD — оказалось, обязательно), то винда устройство не видит или считает неисправным. Линукс как обычно не привередничает. Еще в winXP почему-то не работает второй COM-порт. Драйвер вроде бы устанавливается, но все равно считается неисправным. Возможно, драйвера от LUFA не предназначены для подобного. В win7 такой проблемы нет, а в win10 драйвера вообще устанавливаются автоматически.


Заключение


Теперь мы научились создавать составные устройства, а также реализовали достаточно простой программатор для STM со встроенной справкой и возможностью даже прошить другую микросхему своей прошивкой. Также из соображений "а почему бы и нет?!" я добавил управление линией DTR, и теперь этот "программатор" можно использовать еще и для прошивки AVR через загрузчик Ардуины:


test_arduino:
    stty -F /dev/tty_STFLASH_0 9600
    stty -F /dev/tty_STFLASH_0 50
    avrdude -c arduino -p atmega8 -P /dev/tty_STFLASH_0 -b 115200 -Uflash:r:/dev/null:i
    stty -F /dev/tty_STFLASH_0 50
    echo 'z' > /dev/tty_STFLASH_0

Кстати, подобной штукой оказалось довольно удобно программировать gd32vf103, контроллер на ядре RISC-V, долгое время не соглашавшийся на обычные OpenOCD и ST-LINK (сейчас, говорят, исправили). Эта подобная, только более сложная, штука (плюс HID, плюс микрофон, минус флешка), сейчас планируется для использования для удаленного программирования, отладки и имитации периферии в учебных целях.


Исходный код, как обычно, доступен на github (часть основного репозитория)

Комментарии (7)


  1. jpegqs
    08.07.2023 02:25
    +2

    Для gd32vf103 я бы порекомендовал этот репозиторий: https://github.com/esmil/gd32vf103inator (местами находил баги, но в целом хороший пример для старта).

    Перепрошивается через dfu-util-0.11.

    Кстати, у них с частотой проблемы, у моего gd32vf103 USB отказывается работать на 108MHz, только на 96MHz.


    1. DoHelloWorld
      08.07.2023 02:25
      +1

      Может там с PLL что-то? USB вроде требует 48 МГц, и если для 96 делитель простой - 2, то для 108 такой делитель не прокатит


  1. AndGry
    08.07.2023 02:25

    Очень вовремя ваша статья попалась, спасибо


  1. khajiit
    08.07.2023 02:25
    +2

    Эх, где эта статья была лет 7 назад, когда пришлось мастырить клавиатуру с ком-портом…


    Спасибо, хорошо разобрано.
    Название, правда, ввело в заблуждение: показалось, что речь идет о сдвиговых регистрах °-О-о-°


  1. ionicman
    08.07.2023 02:25

    А возможно сделать одно из устройств виртуальной сетевой картой?
    Ибо настраивать параметры через вебинтерфейс очень было бы удобно.


    Пока пытаюсь сделать msd, при сохранении на которой конфиг парсится и уходит в контроллер.


    1. COKPOWEHEU Автор
      08.07.2023 02:25

      Технически можно. Если я правильно понял, оно называется RNDIS. Тоже было бы интересно с этой штукой поразвлекаться, но вряд ли в ближайшее время.


      Пока пытаюсь сделать msd, при сохранении на которой конфиг парсится и уходит в контроллер.

      А что именно не получается? Или у вас конфиг больше 512 байт? Так лучше разделить на отдельные файлы, контроллеру так проще будет.


      1. ionicman
        08.07.2023 02:25

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