Pull to refresh

USB на регистрах: STM32L1 / STM32F1

Reading time18 min
Views19K


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

С программным USB на примере AVR мы уже познакомились, пришла пора взяться за более тяжелые камни — STM32. Подопытными у нас будут классический STM32F103C8T6 а также представитель малопотребляющей серии STM32L151RCT6. Как и раньше, пользоваться покупными отладочными платами и HAL'ом не будем, отдав предпочтение велосипеду.

Раз уж в заглавии указано два контроллера, стоит рассказать об основных отличиях. В первую очередь это резистор подтяжки, говорящий usb-хосту, что в него что-то воткнули. В L151 он встроен и управляется битом SYSCFG_PMC_USB_PU, а в F103 — нет, придется впаивать на плату снаружи и соединять либо с VCC, либо с ножкой контроллера. В моем случае под руку попалась ножка PA10. На которой висит UART1… А другой вывод UART1 конфликтует с кнопкой… замечательную плату я развел, не находите? Второе отличие это объем флеш-памяти: в F103 ее 64 кБ, а в L151 целых 256 кБ, чем мы когда-нибудь и воспользуемся при изучении конечных точек типа Bulk. Также у них немного отличаются настройки тактирования, да и лампочками с кнопочками могут на разных ногах висеть, но это уже совсем мелочи. Пример для F103 доступен в репозитории, так что адаптировать под него остальные эксперименты с L151 будет несложно. Исходные коды доступны тут: github.com/COKPOWEHEU/usb

Общий принцип работы с USB


Работа с USB в данном контроллере предполагается с использованием аппаратного модуля. То есть мы ему говорим что делать, он делает и в конце дергает прерывание «я готовое!». Соответственно, из основного main'а нам вызывать почти ничего не надо (хотя функцию usb_class_poll я на всякий случай предусмотрел). Обычный цикл работы ограничен единственным событием — обмен данными. Остальные — сброс, сон и прочие — события исключительные, разовые.

В низкоуровневые подробности обмена я на этот раз углубляться не буду. Кому интересно, может почитать про vusb. Но напомню, что обмен обычными данными идет не по одному байту, а по пакету, причем направление передачи задает хост. И названия этих направлений диктует тоже он: IN передача означает что хост принимает данные (а устройство передает), а OUT — что хост передает данные (а мы принимаем). Более того, каждый пакет имеет свой адрес — номер конечной точки, с которой хост хочет общаться. Пока что у нас будет единственная конечная точка 0, отвечающая за устройство в целом (для краткости я еще буду называть ее ep0). Для чего нужны остальные я расскажу в других статьях. Согласно стандарту, размер ep0 составляет строго 8 байт для низкоскоростных устройств (к каковым относится все тот же vusb) и на выбор 8, 16, 32, 64 байта для полноскоростных вроде нашего.

А если данных слишком мало и они не заполняют буфер полностью? Тут все просто: помимо данных в пакете передается и их размер (это может быть поле wLength либо низкоуровневая комбинация сигналов SE0, обозначающая конец передачи), так что даже если нам надо через ep0 размером 64 байта передать три байта, то переданы будут именно три байта. В результате расходовать пропускную способность, гоняя ненужные нули, мы не будем. Так что не стоит мельчить: если можем себе позволить потратить 64 байта, тратим не раздумывая. Помимо прочего это несколько уменьшит загруженность шины, ведь передать за раз кусок в 64 байта (плюс все заголовки и хвосты) проще, чем 8 раз по 8 байт (к каждому из которых опять-таки заголовки и хвосты).
UPD: Как оказалось, размер буферов под USB в этих контроллерах всего 512 байт, так что экономить все-таки придется

А если данных напротив слишком много? Тут сложнее. Данные приходится разбивать по размеру конечной точки и передавать порциями. Скажем, размер ep0 у нас 8 байт, а передать хост пытается 20 байт. При первом прерывании к нам придут байты 0-7, во втором 8-15, в третьем 16-20. То есть чтобы собрать посылку целиком нужно получить целых три прерывания. Для этого в том же HAL придуман хитрый буфер, с которым я попытался разобраться, но после четвертого уровня пересылки одного и того же между функциями, плюнул. Как результат, в моей реализации буферизация ложится на плечи программиста.

Но хост хотя бы всегда говорит сколько данных пытается передать. Когда же данные передаем мы, надо как-то хитро дернуть низкоуровневые состояния ножек чтобы дать понять что данные закончились. Точнее, дать понять модулю usb, что данные закончились и что надо дернуть ножки. Делается это вполне очевидным способом — записью только части буфера. Скажем, если буфер у нас 8 байт, а мы записали 4, то очевидно, данных у нас всего 4 байта, после которых модуль пошлет волшебную комбинацию SE0 и все будут довольны. А если мы записали 8 байт, это значит что у нас всего 8 байт или что это только часть данных, которая влезла в буфер? Модуль usb считает что часть. Поэтому если мы хотим остановить передачу, то после записи 8-байтного буфера должны записать следом 0-байтный. Это называется ZLP, Zero Length Packet. Про то, как это выглядит в коде, я расскажу чуть позже.

Организация памяти


Согласно стандарту, размер конечной точки 0 может достигать 64 байт. Размер любой другой — аж 1024 байт. Количество точек также может отличаться от устройства к устройству. Те же STM32L1 поддерживают до 7 точек на вход и 7 на выход (не считая ep0). Поскольку с этими буферами активно взаимодействует соответствующая периферия, они вынесены в отдельную область памяти, называемую PMA (packet memory area). Модуль USB взаимодействует с ней напрямую и нумерует начиная с 0, а для контроллера она отображается на общаю память начиная с адреса USB_PMAADDR. Чтобы указать где внутри PMA располагаются буферы каждой конечной точки, в начале выделен массив на 8 элементов каждый со следующей структурой, и только потом собственно область для данных:

typedef struct{
    volatile uint32_t usb_tx_addr;
    volatile uint32_t usb_tx_count;
    volatile uint32_t usb_rx_addr;
    volatile union{
      uint32_t usb_rx_count;
      struct{
        uint32_t rx_count:10;
        uint32_t rx_num_blocks:5;
        uint32_t rx_blocksize:1;
      };
    };
}usb_epdata_t;

Здесь задаются начало буфера передачи, его размер, потом начало буфера приема и его размер. Обратите внимание во-первых, что usb_tx_count задает не собственно размер буфера, а количество данных для передачи. То есть наш код должен записать по адресу usb_tx_addr данные, потом записать в usb_tx_count их размер и только потом дернуть регистр модуля usb что данные мол записаны, передавай. Еще большее внимание обратите внимание на странный формат размера буфера приема: он представляет собой структуру, в которой 10 бит rx_count отвечают за реальное количество прочитанных данных, а вот остальные — уже действительно за размер буфера. Надо же железке знать докуда писать можно, а где начинаются чужие данные. Формат этой настройки тоже довольно интересный: флаг rx_block_size говорит в каких единицах задается размер. Если он сброшен в 0, то в 2-байтных словах, тогда размер буфера равен 2*rx_num_blocks, то есть от 0 до 62. А если выставлен в 1, то в 32-байтных блоках, соответственно размер буфера тогда оказывается 32*rx_num_blocks и лежит в диапазоне от 32 до 512 (да, не до 1024, такое вот ограничение контроллера).

Внимание, грабли! Размер PMA буфера составляет всего 512 байт. Причем указано это не в разделе о USB и не в каком-то еще очевидном месте вроде заголовочных файлов, а как бы между прочим в разделе memory map. Даже ссылку туда поленились шлепнуть. В общем, на все буферы у вас остается 448 байт (поскольку 64 занято под таблицу размещения).

Для размещения буферов в этой области будем использовать полудинамический подход. То есть выделять память по запросу, но не освобождать ее (еще не хватало malloc / free изобретать!). На начало неразмеченного пространства будет указывать переменная lastaddr, изначально указывающая на начало области PMA за вычетом таблицы структур, рассмотренной выше. Ну а при каждом вызове функции настройки очередной конечной точки usb_ep_init() она будет сдвигаться на указанный там размер буфера. И в соответствующую ячейку таблицы будет вносится нужное значение, естественно. Значение этой переменной сбрасывается по событию ресета, после чего тут же следует вызов usb_class_init(), в котором точки настраиваются заново в соответствии с юзерской задачей.

Работа с регистрами приема-передачи


Как было только что сказано, на прием мы читаем сколько же реально данных пришло (поле usb_rx_count), потом читаем сами данные, потом дергаем модуль usb что буфер свободен, можно принимать следующий пакет. На передачу наоборот: записываем данные в буфер, потом выставляем в usb_tx_count сколько же записано и наконец дергаем модуль что буфер заполнен, можно передавать.

Первые грабли начинаются при собственно работе с буфером: он организован не по 32 бита, как весь остальной контроллер, и не по 8 бит, как можно было ожидать. А по 16 бит! В результате запись и чтение в него осуществляются по 2 байта, выровненные по 4 байта. Спасибо, ST, что сделали такое извращение! Как бы скучно без этого жилось! Теперь обычным memcpy не обойтись, придется городить специальные функции. Кстати, если кто любит DMA, то оно такое преобразование делать вроде умеет самостоятельно, хотя я это не проверял.

И тут же вторые грабли с записью в регистры модуля. Дело в том, что за настройку каждой конечной точки — за ее тип (control, bulk и т.д.) и состояние — отвечает один регистр USB_EPnR, то есть просто так в нем бит не поменяешь, надо следить чтобы не попортить остальные. А во-вторых, в этом регистре присутствуют биты аж четырех типов! Одни доступны только на чтение (это замечательно), другие на чтение и запись (тоже нормально), третьи игнорируют запись 0, но при записи 1 меняют состояние на противоположное (начинается веселье), а четвертые напротив игнорируют запись 1, но запись 0 сбрасывает их в 0. Скажите мне, какой наркоман додумался в одном регистре сделать биты, игнорирующие 0 и игнорирующие 1?! Нет, я готов предположить что это сделано ради сохранения целостности регистра, когда к нему обращаются и из кода, и из железа. Но вам что, лень было поставить инвертор чтобы биты сбрасывались записью 1? Или в другом месте инвертор чтобы другие биты инвертировались записью 0? В результате выставление двух битов регистра выглядит так (еще раз спасибо ST за такое извращение):

#define ENDP_STAT_RX(num, stat) do{USB_EPx(num) = ((USB_EPx(num) & ~(USB_EP_DTOG_RX | USB_EP_DTOG_TX | USB_EPTX_STAT)) | USB_EP_CTR_RX | USB_EP_CTR_TX) ^ stat; }while(0)

Ах да, чуть не забыл: доступа к регистру по номеру у них тоже нет. То есть макросы USB_EP0R, USB_EP1R и т.д. у них есть, но вот если номер пришел в переменной, то увы. Пришлось изобретать свой USB_EPx() — а что поделать.

Ну и для соблюдения формальностей укажу, что флаг готовности к чтению (то есть что предыдущие данные мы уже прочитали) задается битовой маской USB_EP_RX_VALID, а к записи (то есть что данные мы записали полностью и их можно передавать) — маской USB_EP_TX_VALID.

Обработка IN и OUT запросов


Возникновение прерывания USB может сигнализировать о разных вещах, но сейчас мы сосредоточимся на запросах обмена данными. Флагом такого события будет бит USB_ISTR_CTR. Если увидели его, можем разбираться с какой точкой хочет общаться хост. Номер точки скрывается под битовой маской USB_ISTR_EP_ID, а направление IN или OUT под битами USB_EP_CTR_TX и USB_EP_CTR_RX соответственно.

Поскольку точек у нас может быть много, и каждая со своим алгоритмом обработки, заведем им всем callback-функции, которые бы вызывались по соответствующим событиям. Например, послал хост данные в endpoint3, мы прочитали USB->ISTR, вытащили оттуда что запрос у нас OUT и что номер точки равен 3. Вот и вызываем epfunc_out[3](3). Номер точки в скобках передается если вдруг юзерский код захочет повесить один обработчик на несколько точек. Ах да, еще в стандарте USB принято входные точки IN помечать взведенным 7-м битом. То есть endpoint3 на выход будет иметь номер 0x03, а на вход — 0x83. Причем это разные точки, их можно использовать одновременно, друг другу они не мешают. Ну почти: в stm32 у них настройка типа (bulk, interrupt, ...) общая и на прием, и на передачу. Так что та же 0x83-я точка IN будет соответствовать callback'у epfunc_in[3](3 | 0x80).

Тот же принцип используется и для ep0. Разница только в том, что ее обработка происходит внутри библиотеки, а не внутри юзерского кода. Но что делать если нужно обрабатывать специфичные запросы вроде какого-нибудь HID — не лезть же ковырять код библиотеки? Для этого предусмотрены специальные callback'и usb_class_ep0_out и usb_class_ep0_in, которые вызываются в специальных местах и имеют специальный формат, рассказывать про который я буду ближе к концу.

Стоит упомянуть еще про один не очень очевидный момент, связанный с возникновением прерываний обработки пакетов. С OUT запросами все просто: данные пришли, вот они. А вот IN прерывание генерируется не тогда, когда хост послал IN запрос, а когда в буфере передачи пусто. То есть по принципу действия это прерывание аналогично прерыванию по опустошению буфера UART. Следовательно, когда мы хотим что-то передать хосту, мы это просто записываем данные в буфер передачи, ждем прерывания IN и дописываем что не поместилось (не забываем про ZLP). И ладно еще с «обычными» endpoint'ами, ими программист управляет, можно пока не обращать внимание. Но вот через ep0 обмен идет всегда. Поэтому и работа с ней должна быть встроена в библиотеку.

Как следствие, начало передачи осуществляется функцией ep0_send, которая записывает в глобальную переменную адрес начала буфера и объем данных для передачи, после чего, обратите внимание, сама в первый раз дергает обработчик события IN. В дальнейшем этот обработчик будет вызываться по аппаратным событиям, но толчок дать все же надо.

Ну а сам обработчик устроен довольно просто: записывает очередной кусок данных в буфер передачи, сдвигает адрес начала буфера и уменьшает количество оставшихся для передачи байтов. Отдельный костыль связан с тем самым ZLP и необходимостью на некоторые запросы отвечать пустым пакетом. В данном случае конец передачи обозначается тем, что адрес данных стал NULL. А пустой пакет — что он равен константе ZLPP. И то и другое происходит при равенстве размера нулю, так что реальной записи не происходит.

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

Логика общения по USB


Сам факт подключения хост определяет по наличию подтягивающего резистора между какой-нибудь линией данных и питанием. Он производит сброс устройства, назначает ему адрес на шине и пытается определить что же именно в него воткнули. Для этого он читает дескрипторы устройства и конфигурации (а если надо, то и специфичные). Также он может прочитать строковые дескрипторы чтобы понять как устройство само себя называет (хотя если пара VID:PID ему знакома, предпочтет вытянуть строки из своей базы данных). После этого хост может подгрузить соответствующий драйвер и работать с устройством уже на понятном ему языке. В «понятный ему язык» входят специфичные запросы и обращения к конкретным интерфейсам и конечным точкам. До этого тоже дойдем, но сначала надо чтобы устройство хотя бы отобразилось в системе.

Обработка SETUP запросов: DeviceDescriptor


Человек, хоть немного ковырявший USB, уже давно должен был насторожиться: COKPOWEHEU, ты говоришь про запросы IN и OUT, но ведь в стандарте прописан еще и SETUP. Да, так и есть, но это — скорее разновидность OUT запроса, специально структурированная и предназначенная исключительно для конечной точки 0. Об ее структуре и особенностях работы и поговорим.

Сама структура выглядит следующим образом:

typedef struct{
  uint8_t bmRequestType;
  uint8_t bRequest;
  uint16_t wValue;
  uint16_t wIndex;
  uint16_t wLength;
}config_pack_t;

Поля этой структуры рассмотрены во множестве источников, но все же напомню.
bmRequestType — битовая маска, биты в которой означают следующее:
7: направление передачи. 0 — от хоста к устройству, 1 — от устройства к хосту. Фактически, это тип следующей передачи, OUT или IN.
6-5: класс запроса
0x00 (USB_REQ_STANDARD) — стандартный (обрабатывать пока будем только их)
0x20 (USB_REQ_CLASS) — специфичные для класса (до них дойдем в следующих статьях)
0x40 (USB_REQ_VENDOR) — специфичные для производителя (надеюсь, не придется их трогать)
4-0: собеседник
0x00 (USB_REQ_DEVICE) — устройство в целом
0x01 (USB_REQ_INTERFACE) — отдельный интерфейс
0x02 (USB_REQ_ENDPOINT) — конечная точка

bRequest — собственно запрос
wValue — небольшое 16-битное поле данных. На случай простых запросов, чтобы не гонять полноценные пересылки.
wIndex — номер получателя. Например, интерфейса, с которым хост хочет пообщаться.
wLength — размер дополнительных данных, если 16 бит wValue недостаточно.

Первым делом при подключении устройства хост пытается узнать что же именно в него воткнули. Для этого он посылает запрос со следующими данными:
bmRequestType = 0x80 (запрос на чтение) + USB_REQ_STANDARD (стандартный) + USB_REQ_DEVICE (к устройству в целом)
bRequest = 0x06 (GET_DESCRIPTOR) — запрос дескриптора
wValue = 0x0100 (DEVICE_DESCRIPTOR) — дескриптор устройства в целом
wIndex = 0 — не используется
wLength = 0 — дополнительных данных нет
После чего шлет запрос IN, куда устройство должно положить ответ. Как мы помним, запрос IN от хоста и прерывание контроллера слабо связаны, так что записывать ответ будем сразу в буфер передатчика ep0. Теоретически, данные из этого, да и всех прочих, дескрипторов привязаны к конкретному устройству, поэтому помещать их в ядро библиотеки бессмысленно. Соответствующие запросы передаются функции usb_class_get_std_descr, которая возвращает ядру указатель на начало данных и их размер. Дело в том, что некоторые дескрипторы могут быть переменного размера. Но DEVICE_DESCRIPTOR к ним не относится. Его размер и структура стандартизованы и выглядят так:

uint8_t bLength; //размер дескриптора
uint8_t bDescriptorType; //тип дескриптора. В данном случае USB_DESCR_DEVICE (0x01)
uint16_t bcdUSB; //число 0x0110 для usb-1.1, либо 0x0200 для 2.0. Других значений я не встречал
uint8_t bDeviceClass; //класс устройства
uint8_t bDeviceSubClass; //подкласс
uint8_t bDeviceProtocol; //протокол
uint8_t bMaxPacketSize0; //размер ep0
uint16_t idVendor; // VID
uint16_t idProduct; // PID
uint16_t bcdDevice_Ver; //версия в BCD-формате
uint8_t iManufacturer; //номер строки названия производителя
uint8_t iProduct; //номер строки названия продукта
uint8_t iSerialNumber; //номер строки версии
uint8_t bNumConfigurations; //количество конфигураций (почти всегда равно 1)

В первую очередь обратите внимание на первые два поля — размер дескриптора и его тип. Они характерны почти для всех дескрипторов USB (кроме HID, пожалуй). Причем если bDescriptorType это константа, то bLength приходится чуть ли не считать вручную для каждого дескриптора. В какой-то момент мне это надоело и был написан макрос

#define ARRLEN1(ign, x...) (1+sizeof((uint8_t[]){x})), x

Он считает размер переданных ему аргументов и подставляет вместо первого. Дело в том, что иногда дескрипторы бывают вложенными, так что один, скажем, требует размер в первом байте, другой в 3 и 4 (16-битное число), а третий в 6 и 7 (снова 16-битное число). Точные значения аргументов макросам безразличны, но хотя бы количество совпадать должно. Собственно, макросы для подстановки в 1, в 3 и 4, а также в 6 и 7 байты там тоже есть, но их применение я покажу на более характерном примере.

Пока же рассмотрим 16-битные поля вроде VID и PID. Понятное дело что в одном массиве смешать 8-битные и 16-битные константы не выйдет, да плюс endiannes… в общем, на выручку снова приходят макросы: USB_U16( x ).

В плане выбора VID:PID вопрос сложный. Если планируется выпускать продукцию серийно, все же стоит купить персональную пару. Для личного же пользования можно подобрать чужую от похожего устройства. Скажем, у меня в примерах будут пары от AVR LUFA и STM. Все равно хост определяет по этой паре скорее специфичные баги реализации, чем назначение. Потому что назначение устройства подробно расписывается в специальном дескрипторе.

Внимание, грабли! Как оказалось, Windows привязывает к этой паре драйвера, то есть вы, например, собрали устройство HID, показали системе и установили драйвера. А потом перепрошили устройство под MSD (флешку), не меняя VID:PID, то драйвера останутся старые и, естественно, работать устройство не будет. Придется лезть в «управление оборудованием», удалять драйвера и заставлять систему найти новые. Я думаю, ни для кого не станет неожиданностью, что в Linux такой проблемы нет: устройства просто подключаются и работают.

StringDescriptor


Еще одной интересной особенностью дескрипторов USB является любовь к строкам. В шаблоне дескриптора они обозначаются префиксом i, как например iSerialNumber или iPhone. Эти строки входят во многие дескрипторы и, честно говоря, я не знаю, зачем их так много. Тем более что при подключении устройства видны будут только iManufacturer, iProduct и iSerialNumber. Как бы то ни было, строки представляют собой те же дескрипторы (то есть поля bLength и bDescriptorType в наличии), но вместо дальнейшей структуры идет поток 16-битных символов, похожих на юникод. Смысл данного извращения мне опять непонятен, ведь подобные названия даются все равно обычно на английском, где и 8-битного ASCII хватило бы. Ну хорошо, хотите расширенный набор символов, так UTF-8 бы взяли. Странные люди… Для удобного формирования строк удобно применять — угадайте что — правильно, макросы. Но на этот раз не моей разработки, а подсмотренные у EddyEm. Поскольку строки являются дескрипторами, то и запрашивать их хост будет как обычные дескрипторы, только в поле wValue подставит 0x0300 (STRING_DESCRIPTOR). А вместо младшего байта будет собственно индекс строки. Скажем, запрос 0x0300 это строка с индексом 0 (она зарезервирована под язык устройства и почти всегда равна u"\x0409"), а запрос 0x0302 — строка с индексом 2.

Внимание, грабли! Сколь бы ни был велик соблазн засунуть в iSerialNumber просто строку, даже строку с честной версией вида u''1.2.3'' — не делайте этого! Некоторые операционные системы считают, что там должны быть только шестнадцатеричные цифры, то есть '0'-'9', 'A'-'Z' и все. Даже точек нельзя. Наверное, они как-то считают от этого «числа» хэш чтобы идентифицировать при повторном подключении, не знаю. Но проблему такую заметил при тестировании на виртуальной машине с Windows 7, она считала устройство бракованным. Что интересно, Windows XP и 10 проблему не заметили.

ConfigurationDescriptor


С точки зрения хоста устройство представляет набор отдельных интерфейсов, каждый из которых предназначен для решения какой-то задачи. В дескрипторе интерфейса описывается его устройство и привязанные конечные точки. Да, конечные точки описываются не сами по себе, а только как часть интерфейса. Обычно интерфейсы со сложной архитектурой управляются SETUP запросами (то есть через ep0), в которых поле wIndex номеру интерфейса и соответствует. Максимум позволяется прикарманить конечную точку для прерываний. А от интерфейсов данных хосту нужны только описания конечных точек и обмен будет идти через них.

Интерфейсов в одном устройстве может быть много, причем очень разных. Поэтому чтобы не путаться где заканчивается один интерфейс и начинается другой, в дескрипторе указывается не только размер «заголовка», но и отдельно (обычно 3-4 байтами) полный размер интерфейса. Таким образом интерфейс складывается подобно матрешке: внутри общего контейнера (который хранит размер «заголовка», bDescriptorType и полный размер содержимого, включая заголовок) может находиться еще парочка контейнеров поменьше, но устроенных точно так же. А внутри еще и еще. Приведу пример дескриптора примитивного HID-устройства:


static const uint8_t USB_ConfigDescriptor[] = {
  ARRLEN34(
  ARRLEN1(
    bLENGTH, // bLength: Configuration Descriptor size
    USB_DESCR_CONFIG,    //bDescriptorType: Configuration
    wTOTALLENGTH, //wTotalLength
    1, // bNumInterfaces
    1, // bConfigurationValue: Configuration value
    0, // iConfiguration: Index of string descriptor describing the configuration
    0x80, // bmAttributes: bus powered
    0x32, // MaxPower 100 mA
  )
  ARRLEN1(
    bLENGTH, //bLength
    USB_DESCR_INTERFACE, //bDescriptorType
    0, //bInterfaceNumber
    0, // bAlternateSetting
    0, // bNumEndpoints
    HIDCLASS_HID, // bInterfaceClass: 
    HIDSUBCLASS_NONE, // bInterfaceSubClass: 
    HIDPROTOCOL_NONE, // bInterfaceProtocol: 
    0x00, // iInterface
  )
  ARRLEN1(
    bLENGTH, //bLength
    USB_DESCR_HID, //bDescriptorType
    USB_U16(0x0101), //bcdHID
    0, //bCountryCode
    1, //bNumDescriptors
    USB_DESCR_HID_REPORT, //bDescriptorType
    USB_U16( sizeof(USB_HIDDescriptor) ), //wDescriptorLength
  )
  )
};

Здесь уровень вложенности небольшой, плюс ни одной конечной точки не описано — ну так я старался выбрать устройство попроще. Некоторое недоумение здесь могут вызвать константы bLENGTH и wTOTALLENGTH, равные восьми- и шестнадцатибитному нулям. Поскольку в данном случае для расчета размера используются макросы, было бы странно дублировать их работу и считать байты руками. Как и странно писать нули. А константы штука заметная, наглядности кода способствующая.

Как можно видеть, данный дескриптор состоит из «заголовка» USB_DESCR_CONFIG (хранящего полный размер содержимого включая себя!), интерфейса USB_DESCR_INTERFACE (описывающего подробности устройства) и USB_DESCR_HID, в общих чертах говорящего что же именно за HID мы изображаем. Причем именно что в общих чертах: конкретная структура HID описывается в специальном дескрипторе HID_REPORT_DESCRIPTOR, рассматривать который я здесь не буду, просто потому что слишком плохо его знаю. Так что ограничимся копипастом из какого-нибудь примера.

Вернемся к интерфейсам. Учитывая, что у них есть номера, логично предположить, что в одном устройстве интерфейсов может быть много. Причем они могут отвечать как за одну общую задачу (скажем, интерфейс управления USB-CDC и интерфейс данных), так и за принципиально несвязанные. Скажем, ничто не мешает нам (кроме отсутствия знаний пока) на одном контроллере реализовать два переходника USB-CDC плюс флешку плюс, скажем, клавиатуру. Очевидно, что интерфейс флешки знать не знает про COM-порт. Впрочем, тут есть свои подводные камни, которые, надеюсь, когда-нибудь рассмотрим. Еще стоит отметить, что один интерфейс может иметь несколько альтернативных конфигураций (bAlternateSetting), отличающихся, скажем, количеством конечных точек или частотой их опроса. Собственно, для того и сделано: если хост считает, что лучше пропускную способность поберечь, он может переключить интерфейс в какой-нибудь альтернативный режим, который ему больше понравился.

Обмен данными с HID


Вообще говоря, HID-устройства имитируют объекты реального мира, у которых есть не столько данные, сколько набор неких параметров, которые можно измерять или задавать (запросы SET_REPORT / GET_REPORT) и которые могут уведомлять хост о внезапном внешнем событии (INTERRUPT). Таким образом, собственно для обмена данными данные устройства не предназначены… но кого это когда останавливало!

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

Начнем с более простого — чтения по запросу HIDREQ_GET_REPORT. По сути это такой же запрос, как и всякие DEVICE_DESCRIPTOR, только специфичный именно для HID. Плюс этот запрос адресован не устройству в целом, а интерфейсу. То есть если мы реализовали в одном устройстве несколько независимых HID-устройств, их можно различить по полю wIndex запроса. Правда, именно для HID это не лучший подход: проще сам дескриптор сделать составным. В любом случае до таких извращений нам далеко, так что даже не будем анализировать что и куда хост пытался послать: на любой запрос к интерфейсу и с полем bRequest равным HIDREQ_GET_REPORT будем возвращать собственно данные. По идее, такой подход предназначен чтобы возвращать дескрипторы (со всеми bLength и bDescriptorType), но в случае HID разработчики решили все упростить и обмениваться только данными. Вот и возвращаем указатель на нашу структуру и ее размер. Ну и небольшая дополнительная логика вроде обработки кнопок и счетчика запросов.

Более сложный случай — запрос на запись. Это первый раз, когда мы сталкиваемся с наличием дополнительных данных в SETUP запросе. То есть ядро нашей библиотеки должно сначала прочитать сам запрос, и только потом данные. И передать их юзерской функции. А буфера у нас, напоминаю, нет. В результате некоторой низкоуровневой магии был разработан следующий алгоритм. Callback вызывать будем всегда, но укажем ему с какого по счету байта данные сейчас лежат в буфере приема конечной точки (offset) а также размер этих данных (size). То есть при приеме самого запроса значения offset и size равны нулю (данных-то нет). При приеме первого пакета offset все еще равен нулю, а size — размеру принятых данных. Для второго offset будет равен размеру ep0 (потому что если данные пришлось разбивать, делают это по размеру конечной точки), а size — размеру принятых данных. И так далее. Важно! Если данные приняты, их надо считать. Это может сделать либо обработчик вызовом usb_ep_read() и возвратом 1 (мол «я там сам считал, не утруждайся»), либо просто вернув 0 («мне эти данные не нужны») без чтения — тогда очисткой займется ядро библиотеки. По этому принципу и построена функция: она проверяет в наличии ли данные и если да, то читает их и зажигает светодиоды.

Софт для обмена данными


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

Заключение


Вот, собственно, и все. Основы работы с USB при помощи аппаратного модуля в STM32 я рассказал, некоторый грабли тоже пощупал. Учитывая значительно меньший объем кода, чем тот ужас, что генерирует STMCube, разобраться в нем будет проще. Собственно говоря, в Cube'ической лапше я так и не разобрался, уж больно много там вызовов одного и того же в разных комбинациях. Гораздо лучше для понимания вариант от EddyEm, от которого я отталкивался. Конечно, и там не без косяков, но хотя бы пригодно для понимания. Также похвастаюсь, что размер моего варианта едва ли не в 5 раз меньше ST'шного (~2.7 кБ против 14) — при том, что оптимизацией я не занимался и наверняка можно еще ужать.

Отдельно хочу отметить разницу поведения различных операционных систем при подключении сомнительного оборудования. Linux просто работает, даже если в дескрипторах ошибки. Windows XP, 7, 10 при малейших ошибках ругаются что «устройство поломанное, я с ним работать отказываюсь». Причем XP иногда даже в BSOD падала от негодования. Ах да, еще они постоянно выводят «устройство может работать быстрее», что с этим делать я не знаю. UPD: похоже, это решается выставлением поля BCD_USB в 1.1 (USB_U16(0x0110) ), правда я не знаю будут ли побочные эффекты. В общем, как бы хорош Linux не был для разработки, он прощает слишком многое, тестировать надо и на менее юзер-френдли системах.

Дальнейшие планы: рассмотреть остальные типы конечных точек (пока что был пример только с Control); рассмотреть другие контроллеры (скажем, у меня еще at90usb162 (AVR) и gd32vf103 (RISC_V) валяются), но это совсем далекие планы. Также хорошо бы поподробнее разобраться с отдельными USB-устройствами вроде тех же HID, но тоже не приоритетная задача.
Tags:
Hubs:
+31
Comments11

Articles

Change theme settings