Мне хотелось бы верить, что хотя бы половина читателей может расшифровать хотя бы половину названия статьи :) Кто не в курсе — поясню. Мое устройство должно реализовывать сразу две USB функции:
- Mass Storage Device (он же Mass Storage Class — MSC). Я хочу, чтобы мой девайс прикидывался обычной флешкой и отдавал файлики с данными, которые лежат на SD карте.
- Другая функция это виртуальный COM порт (он же в терминологии USB называется Communication Device Class — CDC). Через этот канал у меня идет всякий дебажный вывод, который удобно смотреть обычным терминалом.
В большинстве примеров по работе с USB реализуется только один тип устройства — флешка, мышка, кастомное HID устройство или виртуальный COM порт. А вот найти вменяемое объяснение как реализовать хотя бы две функции одновременно оказалось не так просто. В своей статье я хотел бы восполнить этот пробел.
Я буду описывать создание композитного USB устройства на базе микроконтроллера STM32, но сам подход будет также применим и для других микроконтроллеров. В статье я детально разберу каждый из классов по отдельности, так и принцип построения композитных устройств. Но обо все по порядку.
Итак, поехали!
Немного теории
Интерфейс USB очень сложный, многоуровневый и многогранный. С наскоку его не осилить. В одной из статей (забыл, правда, в какой) видел фразу в стиле “прочитайте эту статью 2 раза, а потом на утро еще раз”. Да, он такой, с первого раза точно не осилишь. Лично у меня интерфейс более-менее разложился по полочкам только через пару месяцев активного копания и чтения спецификаций.
Я по прежнему не являюсь экспертом в USB, а потому рекомендовал бы обратиться к статьям, которые бы детальнее рассказали суть происходящего. Я лишь укажу на самые важные места и вкратце поясню как оно работает — по большей части во что вляпался сам. В первую очередь я бы рекомендовал Usb in a nutshell (перевод), а также USB Made Simple (сам не читал, но многие рекомендуют). Также нам понадобятся спецификации для конкретных классов USB устройств.
Наверное, самой главной штукой в интерфейсе USB является дескриптор. Точнее даже пакет дескрипторов. Когда устройство подключается к шине хост запрашивает дескрипторы устройства, которые описывают возможности устройства, скорости обмена, частоту опроса, какие интерфейсы реализовывает устройство и много чего другого. Дескриптор штука важная и весьма нежная — даже ошибка в одном байте приведет к тому, что устройство работать не будет.
Устройство описывает себя с помощью нескольких дескрипторов разного типа:
- Дескриптор устройства (Device Descriptor) — описывает устройство в целом, его название, производитель, серийный номер. Строковые данные описываются отдельными строковыми дескрипторами (String Descriptor)
- Дескриптор конфигурации (Configuration Descriptor) — устройство может иметь одну или несколько конфигураций. Каждая конфигурация определяет скорость общения с устройством, набор интерфейсов и параметры питания. Так, например, ноутбук, который работает от батареи, может попросить устройство (выбрать конфигурацию) использовать более низкую скорость обмена и переключиться на собственный источник питания (вместо ноутбучной батареи). Разумеется это работает только если устройство предоставляет такую конфигурацию.
- Дескриптор интерфейса (Interface descriptor) — описывает интерфейс общения с устройством. Интерфейсов может быть несколько. Например разные функции (MSC, CDC, HID) будут реализовывать свои интерфейсы. Некоторые функции (например CDC или DFU) реализуют сразу несколько интерфейсов для своей работы. В нашем случае композитного устройства нам потребуется реализовать сразу несколько интерфейсов от разных функций и заставить их ужиться друг с другом.
- Дескриптор конечной точки (Endpoint descriptor) — описывает канал связи в рамках конкретного интерфейса, задает размер пакета, описывает параметры прерываний. Используя конечные точки мы будем получать и принимать данные.
- Есть еще куча разных дескрипторов, которые описывают отдельные аспекты конкретных интерфейсов
Хост запрашивает дескрипторы одним потоком байт. Очень важно, чтобы в пределах одной конфигурации дескрипторы шли в определенном порядке, иначе хост запутается какой дескриптор к чему. Каждая конфигурация состоит из дескриптора конфигурации и набора дескрипторов описывающих интерфейсы. Каждый интерфейс описывается дескриптором интерфейса и набором дескрипторов конечных точек. Каждая сущность возит свои дескрипторы рядом.
Еще нужно понимать, что USB это хост ориентированный протокол. Настройка устройства, прием, передача — все в USB управляется со стороны хоста. Для нас это означает, что со стороны микроконтроллера нет никакого потока управления — вся работа с USB построена на прерываниях и обратных вызовах (callback). А это, в свою очередь, означает что нам не желательно запускать долгоиграющие операции и нужно быть очень аккуратными при взаимодействии с другими прерываниями (учитывать приоритет, и все такое прочее). Впрочем, попробуем не опускаться на такой низкий уровень.
Также хост-ориентированность проявляется еще и в названии функций. В терминологии USB направление от хоста к устройству называется OUT, хотя для контроллера это прием. И наоборот, направление от устройства к хосту называется IN, хотя для нас это означает отправку данных. Так что в микроконтроллере функция DataOut() на самом деле принимает данные, а DataIn() — отправляет. Но это так, к слову — мы будем пользоваться уже готовым кодом.
CDC — виртуальный COM порт
Наверное взять и сразу наваять композитное устройство целиком не выйдет — слишком много нюансов и подводных камней. Я думаю лучше будет сначала отладить каждый интерфейс в отдельности, а потом переходить к композитному устройству. Начну с CDC, т.к. он не требует никаких зависимостей.
Я недавно переехал на STM32 Cube — пакет низкоуровневых драйверов для STM32. В нем есть код по управлению USB с реализацией отдельных классов USB устройств. Возьмем шаблонные реализации USB Core и CDC и начнем пилить под себя. Заготовки лежат в директории MiddlewaresSTSTM32_USB_Device_Library. Я использую Cube для контроллеров серии STM32F1, версия Cube — 1.6 (Апрель 2017), версия библиотеки USB из комплекта — 2.4.2 (декабрь 2015)
Шаблонная реализация библиотеки подразумевает написание собственного кода в файлах с названием template. Без понимания всей библиотеки и принципов работы USB это сделать достаточно сложно. Но мы пойдем проще — сгенерируем эти файлы с помощью графического конфигуратора CubeMX.
Реализация предоставленная CubeMX готова к работе прямо из коробки. Аж даже немного обидно, что не пришлось писать никакого кода. Придется изучать CDC на примере полностью готовой реализации. Давайте взглянем на самые интересные места в сгенерированном коде.
Для начала заглянем в дескрипторы, которые находятся в файлах usbd_desc.c (дескриптор устройства) и usbd_cdc.c (дескрипторы конфигурации, интерфейсов, конечных точек). В статье usb in a nutshell (на русском) есть очень детальное описание всех дескрипторов. Не буду описывать каждое поле в отдельности, остановлюсь лишь на самых важных и интересных полях.
#define USBD_VID 1155
#define USBD_LANGID_STRING 1033
#define USBD_MANUFACTURER_STRING "STMicroelectronics"
#define USBD_PID_FS 22336
#define USBD_PRODUCT_STRING_FS "STM32 Virtual ComPort"
#define USBD_SERIALNUMBER_STRING_FS "00000000001A"
#define USBD_CONFIGURATION_STRING_FS "CDC Config"
#define USBD_INTERFACE_STRING_FS "CDC Interface"
#define USBD_MAX_NUM_CONFIGURATION 1
/* USB Standard Device Descriptor */
__ALIGN_BEGIN uint8_t USBD_FS_DeviceDesc[USB_LEN_DEV_DESC] __ALIGN_END =
{
0x12, /*bLength */
USB_DESC_TYPE_DEVICE, /*bDescriptorType*/
0x00, /* bcdUSB */
0x02,
0x02, /*bDeviceClass*/
0x02, /*bDeviceSubClass*/
0x00, /*bDeviceProtocol*/
USB_MAX_EP0_SIZE, /*bMaxPacketSize*/
LOBYTE(USBD_VID), /*idVendor*/
HIBYTE(USBD_VID), /*idVendor*/
LOBYTE(USBD_PID_FS), /*idVendor*/
HIBYTE(USBD_PID_FS), /*idVendor*/
0x00, /*bcdDevice rel. 2.00*/
0x02,
USBD_IDX_MFC_STR, /*Index of manufacturer string*/
USBD_IDX_PRODUCT_STR, /*Index of product string*/
USBD_IDX_SERIAL_STR, /*Index of serial number string*/
USBD_MAX_NUM_CONFIGURATION /*bNumConfigurations*/
} ;
/* USB_DeviceDescriptor */
Тут нас интересуют такие поля:
- bDeviceClass, bDeviceSubClass и bDeviceProtocol — описывают хосту что же это у нас за устройство такое, что оно умеет и какие драйвера нужно грузить. В данном случае тут сказано, что устройство у нас реализует Communication Device Class, а значит хосту нужно сделать виртуальный COM порт и связать его с этим устройством
- PID (Product ID) и VID (Vendor ID) — по этим полям хост различает разные устройства, подключенные к системе. Устройства при этом реализовывать одинаковый класс. Говорят для устройств продаваемых на рынке очень важно иметь уникальные VID/PID, но я не узнавал кто и где выдает эти ID-шники. Для домашнего устройства в единственном экземпляре достаточно значений по умолчанию
Обратите внимание, что строковые константы (название устройства, серийный номер) не прописаны в самом дескрипторе. Строки описываются отдельными дескрипторами, а все остальные только указывают индекс строки. Строковый дескриптор в случае библиотеки от ST генерируется на лету (грррррр), поэтому приводить я его не буду.
__ALIGN_BEGIN const uint8_t USBD_CDC_CfgHSDesc[USB_CDC_CONFIG_DESC_SIZ] __ALIGN_END =
{
/*Configuration Descriptor*/
0x09, /* bLength: Configuration Descriptor size */
USB_DESC_TYPE_CONFIGURATION, /* bDescriptorType: Configuration */
USB_CDC_CONFIG_DESC_SIZ, /* wTotalLength:no of returned bytes */
0x00,
0x02, /* bNumInterfaces: 2 interface */
0x01, /* bConfigurationValue: Configuration value */
0x00, /* iConfiguration: Index of string descriptor describing the configuration */
0xC0, /* bmAttributes: self powered */
0x32, /* MaxPower 100 mA */
Тут нам интересно следующее:
- wTotalLength — размер всего пакета дескрипторов для этой конфигурации — чтобы хост знал где заканчивается эта конфигурация и начинается следующая. Нам его нужно будет поправить, когда мы будем делать композитное устройство. Напомню, что все интерфейсы для этой конфигурации должны располагаться сплошным блоком, а значение wTotalLength определяет длину этого блока.
- bNumInterfaces: класс Communication Device реализуется с помощью двух интерфейсов. Один для управления, другой для собственно пересылаемых данных
- bmAttributes и MaxPower указывает, что наше устройство имеет собственный источник питания, но при этом хочет потреблять до 100 мА от USB порта. С этими параметрами явно придется поиграться в будущем.
Дальше идет дескриптор первого из интерфейсов CDC. Этот класс устройств может реализовывать несколько разных моделей общения (телефон, прямое соединение, многостороннее соединение), но в нашем случае это будет Abstract Control Model.
/*Interface Descriptor */
0x09, /* bLength: Interface Descriptor size */
USB_DESC_TYPE_INTERFACE, /* bDescriptorType: Interface */
/* Interface descriptor type */
0x00, /* bInterfaceNumber: Number of Interface */
0x00, /* bAlternateSetting: Alternate setting */
0x01, /* bNumEndpoints: One endpoints used */
0x02, /* bInterfaceClass: Communication Interface Class */
0x02, /* bInterfaceSubClass: Abstract Control Model */
0x01, /* bInterfaceProtocol: Common AT commands */
0x00, /* iInterface: */
В этом интерфейсе живет только одна конечная точка (bNumEndpoints). Но прежде идет серия функциональных дескрипторов — настроек специфичных для данного класса устройств.
/*Header Functional Descriptor*/
0x05, /* bLength: Endpoint Descriptor size */
0x24, /* bDescriptorType: CS_INTERFACE */
0x00, /* bDescriptorSubtype: Header Func Desc */
0x10, /* bcdCDC: spec release number */
0x01,
/*Call Management Functional Descriptor*/
0x05, /* bFunctionLength */
0x24, /* bDescriptorType: CS_INTERFACE */
0x01, /* bDescriptorSubtype: Call Management Func Desc */
0x00, /* bmCapabilities: D0+D1 */
0x01, /* bDataInterface: 1 */
/*ACM Functional Descriptor*/
0x04, /* bFunctionLength */
0x24, /* bDescriptorType: CS_INTERFACE */
0x02, /* bDescriptorSubtype: Abstract Control Management desc */
0x02, /* bmCapabilities */
/*Union Functional Descriptor*/
0x05, /* bFunctionLength */
0x24, /* bDescriptorType: CS_INTERFACE */
0x06, /* bDescriptorSubtype: Union func desc */
0x00, /* bMasterInterface: Communication class interface */
0x01, /* bSlaveInterface0: Data Class Interface */
Тут сказано, что наше устройство не знает о понятии “звонок” (в смысле звонок по телефону), но при этом понимает команды параметров линии (скорость, стоп биты, DTR/CTS биты). Последний дескриптор описывает какой из двух интерфейсов CDC является управляющим, а где бегают данные. В общем, тут нам ничего не интересно и менять мы ничего не будем.
/*Endpoint 2 Descriptor*/
0x07, /* bLength: Endpoint Descriptor size */
USB_DESC_TYPE_ENDPOINT, /* bDescriptorType: Endpoint */
CDC_CMD_EP, /* bEndpointAddress */
0x03, /* bmAttributes: Interrupt */
LOBYTE(CDC_CMD_PACKET_SIZE), /* wMaxPacketSize: */
HIBYTE(CDC_CMD_PACKET_SIZE),
0x10, /* bInterval: */
Тут сказано, что эта конечная точка используется для прерываний. Хост будет опрашивать устройство раз в 0x10 (16) мс с вопросом а не требует ли устройство внимания. Также через эту конечную точку будут ходить управляющие команды.
Описание второго интерфейса (там где данные бегают) будет попроще
/*Data class interface descriptor*/
0x09, /* bLength: Endpoint Descriptor size */
USB_DESC_TYPE_INTERFACE, /* bDescriptorType: */
0x01, /* bInterfaceNumber: Number of Interface */
0x00, /* bAlternateSetting: Alternate setting */
0x02, /* bNumEndpoints: Two endpoints used */
0x0A, /* bInterfaceClass: CDC */
0x00, /* bInterfaceSubClass: */
0x00, /* bInterfaceProtocol: */
0x00, /* iInterface: */
/*Endpoint OUT Descriptor*/
0x07, /* bLength: Endpoint Descriptor size */
USB_DESC_TYPE_ENDPOINT, /* bDescriptorType: Endpoint */
CDC_OUT_EP, /* bEndpointAddress */
0x02, /* bmAttributes: Bulk */
LOBYTE(CDC_DATA_HS_MAX_PACKET_SIZE), /* wMaxPacketSize: */
HIBYTE(CDC_DATA_HS_MAX_PACKET_SIZE),
0x00, /* bInterval: ignore for Bulk transfer */
/*Endpoint IN Descriptor*/
0x07, /* bLength: Endpoint Descriptor size */
USB_DESC_TYPE_ENDPOINT, /* bDescriptorType: Endpoint */
CDC_IN_EP, /* bEndpointAddress */
0x02, /* bmAttributes: Bulk */
LOBYTE(CDC_DATA_HS_MAX_PACKET_SIZE), /* wMaxPacketSize: */
HIBYTE(CDC_DATA_HS_MAX_PACKET_SIZE),
0x00 /* bInterval: ignore for Bulk transfer */
В интерфейсе живут 2 конечные точки типа bulk — одна на прием, вторая на передачу. На самом деле в терминологии USB это одна конечная точка, просто двухсторонняя.
Как это все работает объяснять не буду, хотя бы потому что сам до конца не понимаю (например как хост узнает сколько данных нужно забирать со стороны устройства). Самое главное, что библиотека все реализует за нас. Давайте лучше посмотрим на архитектуру.
Библиотека USB от ST весьма слоиста. Я бы выделил такие архитектурные уровни
- Class Driver (в случае CDC это файлы usbd_cdc и usbd_cdc_if): реализуют логику конкретного класса устройств — CDC для виртуального COM порта, MSC для устройств хранения данных, HID для клавиатур/мышек и всяких специфических устройств с пользовательским интерфейсом.
- USB Core (usbd_core.c, usbd_ctlreq.c, usbd_ioreq.c): реализует общую логику работы всех классов USB устройств, умеет отдавать хосту запрашиваемые дескрипторы, обрабатывает запросы от хоста и настраивает USB устройство в целом. Также перенаправляет потоки данных из уровня драйвера класса в нижележащие уровни и наоборот.
- USB HW Driver (usbd_conf.c): Вышележащие слои платформенно независимые и работают одинаковым образом для нескольких серий микроконтроллеров. В коде нет низкоуровневых вызовов функций конкретного микроконтроллера. Файл usbd_conf.c реализует прослойку между USB Core и HAL — библиотеке низкоуровневых драйверов для выбранного микроконтроллера. В основном тут живут простые врапперы, которые перенаправляют вызовы сверху вниз и коллбеки снизу вверх.
- HAL (stm32f1xx_hal_pcd.c, stm32f1xx_ll_usb.c): занимаются общением с железом микроконтроллера, оперирует регистрами и отвечает на прерывания.
На этом этапе нас будут интересовать только самый верхний слой и одна функция из usbd_conf.c. Начнем с последней:
/**
* @brief Initializes the Low Level portion of the Device driver.
* @param pdev: Device handle
* @retval USBD Status
*/
USBD_StatusTypeDef USBD_LL_Init (USBD_HandleTypeDef *pdev)
{
/* Init USB_IP */
/* Link The driver to the stack */
hpcd_USB_FS.pData = pdev;
pdev->pData = &hpcd_USB_FS;
hpcd_USB_FS.Instance = USB;
hpcd_USB_FS.Init.dev_endpoints = 8;
hpcd_USB_FS.Init.speed = PCD_SPEED_FULL;
hpcd_USB_FS.Init.ep0_mps = DEP0CTL_MPS_8;
hpcd_USB_FS.Init.low_power_enable = DISABLE;
hpcd_USB_FS.Init.lpm_enable = DISABLE;
hpcd_USB_FS.Init.battery_charging_enable = DISABLE;
if (HAL_PCD_Init(&hpcd_USB_FS) != HAL_OK)
{
Error_Handler();
}
HAL_PCDEx_PMAConfig((PCD_HandleTypeDef*)pdev->pData , 0x00 , PCD_SNG_BUF, 0x18);
HAL_PCDEx_PMAConfig((PCD_HandleTypeDef*)pdev->pData , 0x80 , PCD_SNG_BUF, 0x58);
HAL_PCDEx_PMAConfig((PCD_HandleTypeDef*)pdev->pData , 0x81 , PCD_SNG_BUF, 0xC0);
HAL_PCDEx_PMAConfig((PCD_HandleTypeDef*)pdev->pData , 0x01 , PCD_SNG_BUF, 0x110);
HAL_PCDEx_PMAConfig((PCD_HandleTypeDef*)pdev->pData , 0x82 , PCD_SNG_BUF, 0x100);
return USBD_OK;
}
Эта функция инициализирует USB периферию микроконтроллера. Интереснее всего тут серия вызовов функции HAL_PCDEx_PMAConfig(). Дело в том, что на борту микроконтроллера находится цельных 512 байт памяти отведенных специально под буферы USB (эта память называется PMA — Packet Memory Area). Но поскольку заранее устройству неизвестно сколько будет конечных точек и какие будут их параметры, то эта память не распределена. Поэтому перед работой с USB память нужно распределить согласно выбранным параметрам.
Но вот что странно, объявляли только 2 конечные точки, а вызовов 5. Откуда взялись лишние? На самом деле лишних тут нет. Дело в том, что у каждого USB устройства обязательно должна быть одна двусторонняя конечная точка, через которую устройство инициализируется, а потом управляется. Эта конечная точка всегда имеет номер 0. Этой функции инициализируются не конечные точки, а буфера. Для нулевой конечной точки создаются 2 буфера — 0x00 на прием и 0x80 на передачу (старший бит указывает направление передачи, младшие — номер конечной точки). Оставшиеся 3 вызова описывают буфера для конечной точки 1 (прием и передача данных) и конечной точки 2 (прием команд и отсылка статуса — это происходит синхронно, поэтому буфер один)
Последний параметр в каждом вызове указывает смещение буфера конечной точки в общем буфере. На форумах видел вопросы «а что это за магическая константа 0x18 (начальный адрес первого буфера)?». Я детально рассмотрю этот вопрос позже. Сейчас лишь скажу, что первые 0x18 байт PMA памяти занимает таблица распределения буферов.
Но это все кишки и другие внутренности. А что снаружи?
Пользовательский код оперирует функциями приема и передачи, которые находятся в файле usbd_cdc_if.c. Чтобы устройство могло отправлять данные в виртуальный COM порт в сторону хоста нам предоставили функцию CDC_Transmit_FS()
/**
* @brief CDC_Transmit_FS
* Data send over USB IN endpoint are sent over CDC interface
* through this function.
* @note
*
*
* @param Buf: Buffer of data to be send
* @param Len: Number of data to be send (in bytes)
* @retval Result of the operation: USBD_OK if all operations are OK else USBD_FAIL or USBD_BUSY
*/
uint8_t CDC_Transmit_FS(uint8_t* Buf, uint16_t Len)
{
uint8_t result = USBD_OK;
USBD_CDC_HandleTypeDef *hcdc = (USBD_CDC_HandleTypeDef*)hUsbDeviceFS.pClassData;
if (hcdc->TxState != 0){
return USBD_BUSY;
}
USBD_CDC_SetTxBuffer(&hUsbDeviceFS, Buf, Len);
result = USBD_CDC_TransmitPacket(&hUsbDeviceFS);
return result;
}
С приемом чуть сложнее: ядро USB будет дергать функцию CDC_Receive_FS() по мере приема данных. В эту функцию нужно дописать свой код, который будет обрабатывать принятые данные. Или вызывать коллбек, который будет заниматься обработкой, например так:
/**
* @brief CDC_Receive_FS
* Data received over USB OUT endpoint are sent over CDC interface
* through this function.
*
* @note
* This function will block any OUT packet reception on USB endpoint
* untill exiting this function. If you exit this function before transfer
* is complete on CDC interface (ie. using DMA controller) it will result
* in receiving more data while previous ones are still not sent.
*
* @param Buf: Buffer of data to be received
* @param Len: Number of data received (in bytes)
* @retval Result of the operation: USBD_OK if all operations are OK else USBD_FAIL
*/
static int8_t CDC_Receive_FS (uint8_t* Buf, uint32_t *Len)
{
uint16_t len = *Len;
CDCDataReceivedCallback(Buf, len);
// Prepare for next reception
USBD_CDC_ReceivePacket(&hUsbDeviceFS);
return (USBD_OK);
}
Обращаю внимание, что эти функции работают с массивами байт без какой либо структуры. В моем случае мне нужно было отправлять строки. Чтобы это было делать удобно я написал аналог функции printf, которая форматировала строку и отправляла ее в порт. Чтобы повысить скорость я также озадачился двойной буферизацией. Подробнее тут в разделах “USB с двойной буферизацией” и “printf”.
Еще в этом же файле находятся функции инициализации/деинициализации виртуального COM порта, а также функция изменения параметров порта (скорость, четность, стоп биты и прочее). Реализация по умолчанию не ограничивает себя в скорости и это меня устраивает. Инициализация так же хороша. Оставим все как есть.
Финальный штрих — код, который это все запускает
USBD_Init(&hUsbDeviceFS, &FS_Desc, DEVICE_FS);
USBD_RegisterClass(&hUsbDeviceFS, &USBD_CDC);
USBD_CDC_RegisterInterface(&hUsbDeviceFS, &USBD_Interface_fops_FS);
USBD_Start(&hUsbDeviceFS);
Тут по очереди инициализируются разные уровни драйвера. Последняя команда включает USB прерывания. Важно понимать, что вся работа с USB происходит по запросу от хоста. В этом случае внутри драйвера вызывается прерывание, которое в свою очередь либо само обрабатывает запрос, либо делегирует это другому коду через коллбек.
Чтобы это все заработало нужен драйвер со стороны операционной системы. Как правило это стандартный драйвер и система может подхватить устройство без особой процедуры инсталляции. Насколько я понимаю у меня в системе уже был установлен Virtual COM Port драйвер от STM (поставился с ST Flash Utility) и мое устройство подхватилось самостоятельно. На линуксе также все завелось с полпинка.
MSC — запоминающее устройство
С драйвером CDC было все просто — устройство, как правило, само является конечным потребителем данных (например получает от хоста команды) или же генератором (например отправляет хосту показания датчиков).
С Mass Storage Class будет чуток сложнее. Драйвер MSC является всего лишь прослойкой между хостом и шиной USB с одной стороны, и запоминающим устройством с другой. Это может быть SD карта подключенная по SDIO, SPI Flash, может быть RAM Drive, дисковый накопитель, а может быть даже сетевой диск. В общем, в большинстве случаев запоминающее устройство будет представлено неким драйвером (как правило нетривиальным), который нам нужно будет состыковать с реализацией MSC.
В моем устройстве используется SD карта, подключенная через SPI. Для доступа к файлом на этой карте я использую библиотеку SdFat. Она также разделена на несколько уровней абстракции:
- Пользователю предоставляется класс File, через который можно создавать/открывать файлы, читать и писать данные. Клиентский код не парится взаимодействием с носителем информации и тонкостями файловой системы,
- Класс Volume занимается всей кухней по обслуживанию файловой системы, каталога, кластеров, FAT и такого прочего. Общение с носителем данных делегируется в нижележащие уровни.
- Драйвер SD карты — этот компонент знает как общаться с картой, какие ей слать команды и какие слушать ответы. Библиотека предоставляет несколько видов драйверов для карт подключенных по SPI и SDIO. Теоретически можно подставить свой драйвер, например, для RAM диска.
- Вышележащие слои кроссплатформенные, они ничего не знают о том как именно данные будут писаться на карту или читаться с нее. Это позволяет собирать библиотеку под разные платформу (как Ардуино, так и другие). Для конкретной платформы или микроконтроллера можно написать драйвер, который будет реализовывать передачу данных через необходимый интерфейс. По умолчанию библиотека предоставляет несколько драйверов, в т.ч. для ардуиновского SPI, но я заморочился и написал свой драйвер
с преферансом и поэтессамипередачей через DMA на основе HAL - Наконец, HAL обеспечивает работу с регистрами конкретного микроконтроллера
В случае USB Mass Storage мы не будем работать с файлами на флешке — всю работу по интерпретации файловой системы будет делать хост. К устройству будут приходить запросы на чтение или запись конкретного блока данных. Так что нас будут интересовать уровни от драйвера карты и ниже.
Реализация MSC требует от хранилища определенного интерфейса — уметь читать и писать, отдавать свой размер и статус. Примерно такие же возможности предоставляет интерфейс драйвера SD карты библиотеки SdFat. Остается лишь написать адаптер, который приведет один интерфейс к другому.
С направлением движения определились. Займемся реализацией. Я опять воспользовался конфигуратором CubeMX и сгенерировал нужные файлы для компонента USB. Изучение начнем, конечно же, с дескрипторов.
* USB Standard Device Descriptor */
__ALIGN_BEGIN uint8_t USBD_FS_DeviceDesc[USB_LEN_DEV_DESC] __ALIGN_END =
{
0x12, /*bLength */
USB_DESC_TYPE_DEVICE, /*bDescriptorType*/
0x00, /* bcdUSB */
0x02,
0x00, /*bDeviceClass*/
0x00, /*bDeviceSubClass*/
0x00, /*bDeviceProtocol*/
USB_MAX_EP0_SIZE, /*bMaxPacketSize*/
LOBYTE(USBD_VID), /*idVendor*/
HIBYTE(USBD_VID), /*idVendor*/
LOBYTE(USBD_PID_FS), /*idVendor*/
HIBYTE(USBD_PID_FS), /*idVendor*/
0x00, /*bcdDevice rel. 2.00*/
0x02,
USBD_IDX_MFC_STR, /*Index of manufacturer string*/
USBD_IDX_PRODUCT_STR, /*Index of product string*/
USBD_IDX_SERIAL_STR, /*Index of serial number string*/
USBD_MAX_NUM_CONFIGURATION /*bNumConfigurations*/
} ;
/* USB_DeviceDescriptor */
Дескриптор устройства практически не изменился. Разница только в полях, определяющих класс устройства — теперь класс устройства в целом не задан (нули в bDeviceClass), а будет задаваться на уровне интерфейса (это требование спецификации ).
0x09, /* bLength: Configuation Descriptor size */
USB_DESC_TYPE_CONFIGURATION, /* bDescriptorType: Configuration */
USB_MSC_CONFIG_DESC_SIZ,
0x00,
0x01, /* bNumInterfaces: 1 interface */
0x01, /* bConfigurationValue: */
0x04, /* iConfiguration: */
0xC0, /* bmAttributes: */
0x32, /* MaxPower 100 mA */
Очень похоже на аналогичный дескриптор из CDC — определяется количество интерфейсов (1) и параметры питания от шины (до 100 мА)
0x09, /* bLength: Interface Descriptor size */
0x04, /* bDescriptorType: */
0x00, /* bInterfaceNumber: Number of Interface */
0x00, /* bAlternateSetting: Alternate setting */
0x02, /* bNumEndpoints*/
0x08, /* bInterfaceClass: MSC Class */
0x06, /* bInterfaceSubClass : SCSI transparent*/
0x50, /* nInterfaceProtocol */
0x05, /* iInterface: */
Дескриптор интерфейса объявляет 2 конечных точки (по одной в каждую сторону передачи). Также дескриптор определяет какой именно это подкласс Mass Storage — Bulk Only Transport. Я не нашел толкового описания что же именно это за подкласс такой. Предполагаю, что это устройство, которое общается только посредством двусторонней передачи данных через 2 конечные точки (тогда как другие модели могут использовать еще и прерывания). Протоколом в этом общении являются SCSI команды.
0x07, /*Endpoint descriptor length = 7*/
0x05, /*Endpoint descriptor type */
MSC_EPIN_ADDR, /*Endpoint address (IN, address 1) */
0x02, /*Bulk endpoint type */
LOBYTE(MSC_MAX_FS_PACKET),
HIBYTE(MSC_MAX_FS_PACKET),
0x00, /*Polling interval in milliseconds */
0x07, /*Endpoint descriptor length = 7 */
0x05, /*Endpoint descriptor type */
MSC_EPOUT_ADDR, /*Endpoint address (OUT, address 1) */
0x02, /*Bulk endpoint type */
LOBYTE(MSC_MAX_FS_PACKET),
HIBYTE(MSC_MAX_FS_PACKET),
0x00 /*Polling interval in milliseconds*/
Тут определяются 2 конечные точки типа Bulk — интерфейс USB не гарантирует скорость по таким конечным точкам, зато гарантирует доставку данных. Размер пакета устанавливается в 64 байта.
Раз уж мы говорим про конечные точки, то стоит заглянуть в файл usbd_conf.c где определяются соответствующие PMA буфера
HAL_PCDEx_PMAConfig((PCD_HandleTypeDef*)pdev->pData , 0x00 , PCD_SNG_BUF, 0x18);
HAL_PCDEx_PMAConfig((PCD_HandleTypeDef*)pdev->pData , 0x80 , PCD_SNG_BUF, 0x58);
HAL_PCDEx_PMAConfig((PCD_HandleTypeDef*)pdev->pData , 0x81 , PCD_SNG_BUF, 0x98);
HAL_PCDEx_PMAConfig((PCD_HandleTypeDef*)pdev->pData , 0x01 , PCD_SNG_BUF, 0xD8);
Теперь посмотрим на MSC с другой стороны. Этот USB класс принимает от хоста команды на чтение/запись и транслирует их специализированный интерфейс — USBD_StorageTypeDef. Нам остается только подставить свою реализацию.
/** @defgroup USB_CORE_Exported_Types
* @{
*/
typedef struct _USBD_STORAGE
{
int8_t (* Init) (uint8_t lun);
int8_t (* GetCapacity) (uint8_t lun, uint32_t *block_num, uint16_t *block_size);
int8_t (* IsReady) (uint8_t lun);
int8_t (* IsWriteProtected) (uint8_t lun);
int8_t (* Read) (uint8_t lun, uint8_t *buf, uint32_t blk_addr, uint16_t blk_len);
int8_t (* Write)(uint8_t lun, uint8_t *buf, uint32_t blk_addr, uint16_t blk_len);
int8_t (* GetMaxLun)(void);
int8_t *pInquiry;
}USBD_StorageTypeDef;
Поскольку это C, а не C++, то каждая их этих записей — указатель на соответствующую функцию. Как я уже говорил, нам нужно написать адаптер, который будет приводить интерфейс MSC к интерфейсу SD карты.
Начнем реализовывать интерфейс. Первой идет функция инициализации
int8_t SD_MSC_Init (uint8_t lun)
{
(void)lun; // Not used
// if(!initSD())
// return USBD_FAIL;
return (USBD_OK);
}
Так SD карту можно было бы инициализировать прямо отсюда, если бы это была быстрая операция. Но в случае SD карты это может быть не всегда так. К тому же не стоит забывать, что эти все функции являются коллбеками и вызываются из прерывания USB, а прерывания надолго блокировать не стОит. Поэтому я вызвают функцию initSD() прямо из main() перед инициализацией USB, а SD_MSC_Init() у меня ничего не делает
SdFatSPIDriver spiDriver;
SdSpiCard card;
bool initSD()
{
return card.begin(&spiDriver, PA4, SPI_FULL_SPEED);
}
Может показаться, что слишком много разных драйверов, но позвольте я напомню архитектуру. Класс SdSpiCard из библиотеки SdFat знает как общаться с SD картой через SPI, когда и какую команду послать и какой ждать ответ. Но он не знает как работать с самим SPI. Для этих целей я написал класс SdFatSPIDriver, который реализует общение с картой по SPI и передачу данных через DMA.
Идем дальше.
int8_t SD_MSC_GetCapacity (uint8_t lun, uint32_t *block_num, uint16_t *block_size)
{
(void)lun; // Not used
*block_num = card.cardSize();
*block_size = 512;
return (USBD_OK);
}
Реализация SD_MSC_GetCapacity() тривиальна — SdSpiCard умеет возвращать размер карты сразу в блоках
int8_t SD_MSC_Read (uint8_t lun,
uint8_t *buf,
uint32_t blk_addr,
uint16_t blk_len)
{
(void)lun; // Not used
if(!card.readBlocks(blk_addr, buf, blk_len))
return USBD_FAIL;
return (USBD_OK);
}
int8_t SD_MSC_Write (uint8_t lun,
uint8_t *buf,
uint32_t blk_addr,
uint16_t blk_len)
{
(void)lun; // Not used
if(!card.writeBlocks(blk_addr, buf, blk_len))
return USBD_FAIL;
return (USBD_OK);
}
Чтение и запись также реализована вполне просто.
int8_t SD_MSC_IsReady (uint8_t lun)
{
(void)lun; // Not used
return (USBD_OK);
}
int8_t SD_MSC_IsWriteProtected (uint8_t lun)
{
(void)lun; // Not used
return (USBD_OK); // Never write protected
}
Карта у нас всегда готова (хотя в будущем я буду пристальнее смотреть на статус) и не защищена от записи.
int8_t SD_MSC_GetMaxLun (void)
{
return 0; // We have just 1 Logic unit number (LUN) which is zero
}
LUN — Logic Unit Number. Теоретически наше запоминающее устройство может состоять из нескольких носителей (например жесткие диски в рейде). Все функции SCSI протокола указывают с каким носителем оно хочет работать. Функция GetMaxLun возвращает номер последнего устройства (количество устройств минус 1). Флешка у нас одна потому возвращаем 0.
И последняя штука.
const uint8_t SD_MSC_Inquirydata[] = {/* 36 */
/* LUN 0 */
0x00,
0x80,
0x02,
0x02,
(STANDARD_INQUIRY_DATA_LEN - 5),
0x00,
0x00,
0x00,
'S', 'T', 'M', ' ', ' ', ' ', ' ', ' ', /* Manufacturer : 8 bytes */
'P', 'r', 'o', 'd', 'u', 'c', 't', ' ', /* Product : 16 Bytes */
' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ',
'0', '.', '0' ,'1' /* Version : 4 Bytes */
};
Если честно, я особо не разобрался зачем оно нужно. Заглянув в спецификацию SCSI я увидел очень много полей смысла, которых я не понял. Из того, что я осилил – тут описывается стандартное устройство с прямым (не секвентальным) доступом, причем которое может быть извлечено (removable). Благо во всех примерах, которые я видел этот массив совпадает, так что пускай будет. Отлажено ведь.
Теперь все это нужно правильно проинициализировать
USBD_StatusTypeDef res = USBD_Init(&hUsbDeviceFS, &FS_Desc, DEVICE_FS);
USBD_RegisterClass(&hUsbDeviceFS, &USBD_MSC);
USBD_MSC_RegisterStorage(&hUsbDeviceFS, &SdMscDriver);
USBD_Start(&hUsbDeviceFS);
Подключаем, проверяем. Все работает, правда очень медленно — подключенный диск открывается секунд 50. Отчасти это из-за того, что линейная скорость чтения флешки через такой интерфейс получается около 200кб/с. Когда USB Mass Storage устройство подключается к компьютеру, операционная система вычитывает таблицу FAT. Я использую флешку на 8 гиг, а там FAT аж 7.5 мегабайт. Плюс чтение MBR, бут сектора, таблицы файлов — вот и получается почти 50 сек.
Также мне пришлось отключить DMA при работе с SD картой – там не все так просто с его включением. Дело в том, что моя реализация драйвера (как оказалось) не может работать из прерывания, а в USB все только через прерывания и работает. Не работает даже банальный HAL_Delay() т.к. он тоже завязан на прерывания, не говоря уже о синхронизации с использованием FreeRTOS. Это нужно будет переделать, но это отдельная история и к USB composite device она не относится. Как переделаю — обязательно напишу об этом статью и оставлю тут линку.
CDC + MSC Composite Device
А теперь со всей этой фигней мы попробуем взлететь (С) анекдот
Итак, мы уже знаем как строить USB устройства, которые могут реализовывать либо CDC либо MSC. Попробуем сделать композитное устройство, которое реализует оба интерфейса одновременно. Я посмотрел несколько других проектов, которые реализовывали композитное USB устройство и, как мне кажется, их подход имеет смысл. А именно: реализовать собственный драйвер класса, который будет реализовывать и ту и ту функциональность.
Заготовку для класса возьмем из пакета STM32 Cube (MiddlewaresSTSTM32_USB_Device_LibraryClassTemplate). Начинкой будет творчески преработаный код отсюда .
Структура USB устройства будет такая:
- У нас будет всего одна конфигурация, а в ней 3 интерфейса
- Один интерфейс реализует MSC
- У него одна двунаправленная конечная точка — для передачи и приема
- CDC реализуется двумя интерфейсами.
- Первый для управления. У него одна однонаправленная конечная точка для управления интерфейсом
- Второй интерфейс CDC для данных. У него двунаправленная конечная точка — для передачи и приема
- Еще одна конечная точка нужна для управления устройство в целом (реализуется ядром USB библиотеки)
Красивая картинка, которая описывает пример описания композитного устройства. Взято из спецификации IAD
Для удобства использования объявим в коде номера конечных точек и интерфейсов.
#define MSC_INTERFACE_IDX 0x0 // Index of MSC interface
#define CDC_INTERFACE_IDX 0x1 // Index of CDC interface
// endpoints numbers
// endpoints numbers
#define MSC_EP_IDX 0x01
#define CDC_CMD_EP_IDX 0x02
#define CDC_EP_IDX 0x03
#define IN_EP_DIR 0x80 // Adds a direction bit
#define MSC_OUT_EP MSC_EP_IDX /* EP1 for BULK OUT */
#define MSC_IN_EP MSC_EP_IDX | IN_EP_DIR /* EP1 for BULK IN */
#define CDC_CMD_EP CDC_CMD_EP_IDX| IN_EP_DIR /* EP2 for CDC commands */
#define CDC_OUT_EP CDC_EP_IDX /* EP3 for data OUT */
#define CDC_IN_EP CDC_EP_IDX | IN_EP_DIR /* EP3 for data IN */
Нумерация конечных точек повторяет нумерацию интерфейсов. Будем использовать №1 для MSC, №2 для управления CDC, №3 для передачи данных через CDC. Есть еще нулевая конечная точка для общего управления устройством, но она обрабатывается в недрах ядра USB и объявлять эти номера не обязательно.
Интерфейс USB библиотеки от ST оставляет желать лучшего. В некоторых случаях номера конечных точек используются с флагом направления передачи — установленный старший бит означает направление IN — в сторону хоста (я для этого завел константу IN_EP_DIR). При этом другие функции используют просто номер конечной точки. В отличии от оригинального дизайна я предпочел разделить эти все номера и использовать правильные константы в нужных местах. Там где используются константы с суффиксом EP_IDX флаг направления передачи не используется.
ВАЖНО! Хоть по спецификации USB номера конечных точек могут быть какими угодно, все же лучше расположить их последовательно и в том же порядке, в котором они объявляются в дескрипторах. Мне это знание далось неделей жесткого дебага, когда виндовый USB драйвер упорно ломился не в ту конечную точку и ничего не работало.
Начнем как обычно с дескрипторов. Большая часть дескрипторов будут жить в нашей реализации класса (usbd_msc_cdc.c), но дескриптор устройства и кое какие глобальные штуки определены в ядре USB в файле usbd_desc.c
#define USBD_VID 0x0483
#define USBD_PID 0x5741
#define USBD_LANGID_STRING 0x409
#define USBD_MANUFACTURER_STRING "STMicroelectronics"
#define USBD_PRODUCT_FS_STRING "Composite MSC CDC"
#define USBD_SERIALNUMBER_FS_STRING "00000000055C"
#define USBD_CONFIGURATION_FS_STRING "Config Name"
#define USBD_INTERFACE_FS_STRING "Interface Name"
__ALIGN_BEGIN const uint8_t USBD_FS_DeviceDesc[USB_LEN_DEV_DESC] __ALIGN_END =
{
0x12, /*bLength */
USB_DESC_TYPE_DEVICE, /*bDescriptorType*/
0x00, /*bcdUSB */
0x02,
0xEF, /*bDeviceClass*/
0x02, /*bDeviceSubClass*/
0x01, /*bDeviceProtocol*/
USB_MAX_EP0_SIZE, /*bMaxPacketSize*/
LOBYTE(USBD_VID), /*idVendor*/
HIBYTE(USBD_VID), /*idVendor*/
LOBYTE(USBD_PID), /*idVendor*/
HIBYTE(USBD_PID), /*idVendor*/
0x00, /*bcdDevice rel. 2.00*/
0x02,
USBD_IDX_MFC_STR, /*Index of manufacturer string*/
USBD_IDX_PRODUCT_STR, /*Index of product string*/
USBD_IDX_SERIAL_STR, /*Index of serial number string*/
USBD_MAX_NUM_CONFIGURATION /*bNumConfigurations*/
};
В целом тут все тоже самое, отличаются только поля, которые определяют класс устройства (bDeviceClass). Теперь эти поля указывают, что это композитное устройство. Хосту нужно будет потрудится, разобраться во всех остальных дескрипторах и подгрузить правильные драйвера для каждого из компонентов. Поле bDeviceProtocol означает, что части композитного устройства будут описываться специальным дескриптором – дескриптором ассоциации интерфейсов (Interface Association Descriptor). О нем чуть ниже.
Дескриптор конфигурации примерно такой же как и раньше, разница только в количестве интерфейсов. Теперь у нас их 3
#define USB_MSC_CDC_CONFIG_DESC_SIZ 98
/* USB MSC+CDC device Configuration Descriptor */
static const uint8_t USBD_MSC_CDC_CfgDesc[USB_MSC_CDC_CONFIG_DESC_SIZ] =
{
0x09, /* bLength: Configuation Descriptor size */
USB_DESC_TYPE_CONFIGURATION, /* bDescriptorType: Configuration */
USB_MSC_CDC_CONFIG_DESC_SIZ, /* wTotalLength: Bytes returned */
0x00,
0x03, /*bNumInterfaces: 3 interface*/
0x01, /*bConfigurationValue: Configuration value*/
0x02, /*iConfiguration: Index of string descriptor describing the configuration*/
0xC0, /*bmAttributes: bus powered and Supports Remote Wakeup */
0x32, /*MaxPower 100 mA: this current is used for detecting Vbus*/
/* 09 bytes */
Далее идет объявление интерфейса и конечных точек для MSC. Не знаю почему именно в таком порядке (сначала MSC потом CDC). Так было в одном из примеров, которые я нашел, оттуда и скопировал. По идее порядок интерфейсов не имеет значения. Главное, чтобы они возили все свои дополнительные дескрипторы рядом. Ну и приколы с нумерацией конечных точек также имеют значение.
/******************** Mass Storage interface ********************/
0x09, /* bLength: Interface Descriptor size */
0x04, /* bDescriptorType: */
MSC_INTERFACE_IDX, /* bInterfaceNumber: Number of Interface */
0x00, /* bAlternateSetting: Alternate setting */
0x02, /* bNumEndpoints*/
0x08, /* bInterfaceClass: MSC Class */
0x06, /* bInterfaceSubClass : SCSI transparent command set*/
0x50, /* nInterfaceProtocol */
USBD_IDX_INTERFACE_STR, /* iInterface: */
/* 09 bytes */
/******************** Mass Storage Endpoints ********************/
0x07, /*Endpoint descriptor length = 7*/
0x05, /*Endpoint descriptor type */
MSC_IN_EP, /*Endpoint address (IN, address 1) */
0x02, /*Bulk endpoint type */
LOBYTE(USB_MAX_PACKET_SIZE),
HIBYTE(USB_MAX_PACKET_SIZE),
0x00, /*Polling interval in milliseconds */
/* 07 bytes */
0x07, /*Endpoint descriptor length = 7 */
0x05, /*Endpoint descriptor type */
MSC_OUT_EP, /*Endpoint address (OUT, address 1) */
0x02, /*Bulk endpoint type */
LOBYTE(USB_MAX_PACKET_SIZE),
HIBYTE(USB_MAX_PACKET_SIZE),
0x00, /*Polling interval in milliseconds*/
/* 07 bytes */
Дескрипторы MSC ничем не отличаются от тех, что были в предыдущем разделе.
А вот дальше идет новый тип дескриптора — IAD (Interface Association Descriptor) – дескриптор ассоциации интерфейсов. Ассоциация тут не в смысле организации, а в смысле какой интерфейс с какой функцией ассоциировать.
/******** IAD should be positioned just before the CDC interfaces ******
IAD to associate the two CDC interfaces */
0x08, /* bLength */
0x0B, /* bDescriptorType */
CDC_INTERFACE_IDX, /* bFirstInterface */
0x02, /* bInterfaceCount */
0x02, /* bFunctionClass */
0x02, /* bFunctionSubClass */
0x01, /* bFunctionProtocol */
0x00, /* iFunction (Index of string descriptor describing this function) */
/* 08 bytes */
Этот хитрый дескриптор говорит хосту что описание предыдущей функции USB устройства (MSC) закончилось и сейчас будет совсем другая функция. Причем тут же указано какая именно — CDC. Также указано количество связанных с ней интерфейсов и индекс первого из них.
IAD дескриптор не нужен для MSC, т.к. там всего один интерфейс. Но IAD нужен для CDC чтобы сгруппировать 2 интерфейса в одну функцию. Об этом сказано в спецификации этого дескриптора
Наконец дескрипторы CDC. Они полностью соответствуют дескрипторам для одиночной CDC функции с точностью до номеров интерфейсов и конечных точек
/******************** CDC interfaces ********************/
/*Interface Descriptor */
0x09, /* bLength: Interface Descriptor size */
USB_DESC_TYPE_INTERFACE, /* bDescriptorType: Interface */
/* Interface descriptor type */
CDC_INTERFACE_IDX, /* bInterfaceNumber: Number of Interface */
0x00, /* bAlternateSetting: Alternate setting */
0x01, /* bNumEndpoints: One endpoints used */
0x02, /* bInterfaceClass: Communication Interface Class */
0x02, /* bInterfaceSubClass: Abstract Control Model */
0x01, /* bInterfaceProtocol: Common AT commands */
0x01, /* iInterface: */
/* 09 bytes */
/*Header Functional Descriptor*/
0x05, /* bLength: Endpoint Descriptor size */
0x24, /* bDescriptorType: CS_INTERFACE */
0x00, /* bDescriptorSubtype: Header Func Desc */
0x10, /* bcdCDC: spec release number */
0x01,
/* 05 bytes */
/*Call Management Functional Descriptor*/
0x05, /* bFunctionLength */
0x24, /* bDescriptorType: CS_INTERFACE */
0x01, /* bDescriptorSubtype: Call Management Func Desc */
0x00, /* bmCapabilities: D0+D1 */
CDC_INTERFACE_IDX + 1, /* bDataInterface: 2 */
/* 05 bytes */
/*ACM Functional Descriptor*/
0x04, /* bFunctionLength */
0x24, /* bDescriptorType: CS_INTERFACE */
0x02, /* bDescriptorSubtype: Abstract Control Management desc */
0x02, /* bmCapabilities */
/* 04 bytes */
/*Union Functional Descriptor*/
0x05, /* bFunctionLength */
0x24, /* bDescriptorType: CS_INTERFACE */
0x06, /* bDescriptorSubtype: Union func desc */
CDC_INTERFACE_IDX, /* bMasterInterface: Communication class interface */
CDC_INTERFACE_IDX + 1, /* bSlaveInterface0: Data Class Interface */
/* 05 bytes */
/*Endpoint 2 Descriptor*/
0x07, /* bLength: Endpoint Descriptor size */
USB_DESC_TYPE_ENDPOINT, /* bDescriptorType: Endpoint */
CDC_CMD_EP, /* bEndpointAddress */
0x03, /* bmAttributes: Interrupt */
LOBYTE(CDC_CMD_PACKET_SIZE), /* wMaxPacketSize: */
HIBYTE(CDC_CMD_PACKET_SIZE),
0x10, /* bInterval: */
/* 07 bytes */
/*Data class interface descriptor*/
0x09, /* bLength: Endpoint Descriptor size */
USB_DESC_TYPE_INTERFACE, /* bDescriptorType: */
CDC_INTERFACE_IDX + 1, /* bInterfaceNumber: Number of Interface */
0x00, /* bAlternateSetting: Alternate setting */
0x02, /* bNumEndpoints: Two endpoints used */
0x0A, /* bInterfaceClass: CDC */
0x00, /* bInterfaceSubClass: */
0x00, /* bInterfaceProtocol: */
0x00, /* iInterface: */
/* 09 bytes */
/*Endpoint OUT Descriptor*/
0x07, /* bLength: Endpoint Descriptor size */
USB_DESC_TYPE_ENDPOINT, /* bDescriptorType: Endpoint */
CDC_OUT_EP, /* bEndpointAddress */
0x02, /* bmAttributes: Bulk */
LOBYTE(CDC_DATA_PACKET_SIZE), /* wMaxPacketSize: */
HIBYTE(CDC_DATA_PACKET_SIZE),
0x00, /* bInterval: ignore for Bulk transfer */
/* 07 bytes */
/*Endpoint IN Descriptor*/
0x07, /* bLength: Endpoint Descriptor size */
USB_DESC_TYPE_ENDPOINT, /* bDescriptorType: Endpoint */
CDC_IN_EP, /* bEndpointAddress */
0x02, /* bmAttributes: Bulk */
LOBYTE(CDC_DATA_PACKET_SIZE), /* wMaxPacketSize: */
HIBYTE(CDC_DATA_PACKET_SIZE),
0x00, /* bInterval */
/* 07 bytes */
Когда все дескрипторы готовы можно посчитать суммарный размер конфигурации.
#define USB_CDC_CONFIG_DESC_SIZ 98
Перейдем к написанию кода. Ядро USB общается с драйверами классов используя вот такой интерфейс
typedef struct _Device_cb
{
uint8_t (*Init) (struct _USBD_HandleTypeDef *pdev , uint8_t cfgidx);
uint8_t (*DeInit) (struct _USBD_HandleTypeDef *pdev , uint8_t cfgidx);
/* Control Endpoints*/
uint8_t (*Setup) (struct _USBD_HandleTypeDef *pdev , USBD_SetupReqTypedef *req);
uint8_t (*EP0_TxSent) (struct _USBD_HandleTypeDef *pdev );
uint8_t (*EP0_RxReady) (struct _USBD_HandleTypeDef *pdev );
/* Class Specific Endpoints*/
uint8_t (*DataIn) (struct _USBD_HandleTypeDef *pdev , uint8_t epnum);
uint8_t (*DataOut) (struct _USBD_HandleTypeDef *pdev , uint8_t epnum);
uint8_t (*SOF) (struct _USBD_HandleTypeDef *pdev);
uint8_t (*IsoINIncomplete) (struct _USBD_HandleTypeDef *pdev , uint8_t epnum);
uint8_t (*IsoOUTIncomplete) (struct _USBD_HandleTypeDef *pdev , uint8_t epnum);
const uint8_t *(*GetHSConfigDescriptor)(uint16_t *length);
const uint8_t *(*GetFSConfigDescriptor)(uint16_t *length);
const uint8_t *(*GetOtherSpeedConfigDescriptor)(uint16_t *length);
const uint8_t *(*GetDeviceQualifierDescriptor)(uint16_t *length);
#if (USBD_SUPPORT_USER_STRING == 1)
uint8_t *(*GetUsrStrDescriptor)(struct _USBD_HandleTypeDef *pdev ,uint8_t index, uint16_t *length);
#endif
} USBD_ClassTypeDef;
В зависимости от состояния или события на шине USB ядро вызывает соответствующую функцию.
Любую архитектурную проблему можно решить введением дополнительного абстрактного слоя… (С) еще один анекдот
Разумеется мы не будем реализовывать весь функционал целиком — за реализацию классов CDC и MSC будет отвечать существующий код. Мы лишь напишем прослойку, которая будет перенаправлять вызовы либо в одну, либо в другую реализацию.
/**
* @brief USBD_MSC_CDC_Init
* Initialize the MSC+CDC interface
* @param pdev: device instance
* @param cfgidx: Configuration index
* @retval status
*/
static uint8_t USBD_MSC_CDC_Init (USBD_HandleTypeDef *pdev,
uint8_t cfgidx)
{
/* MSC initialization */
uint8_t ret = USBD_MSC_Init (pdev, cfgidx);
if(ret != USBD_OK)
return ret;
/* CDC initialization */
ret = USBD_CDC_Init (pdev, cfgidx);
if(ret != USBD_OK)
return ret;
return USBD_OK;
}
/**
* @brief USBD_MSC_CDC_Init
* DeInitialize the MSC+CDC layer
* @param pdev: device instance
* @param cfgidx: Configuration index
* @retval status
*/
static uint8_t USBD_MSC_CDC_DeInit (USBD_HandleTypeDef *pdev,
uint8_t cfgidx)
{
/* MSC De-initialization */
USBD_MSC_DeInit(pdev, cfgidx);
/* CDC De-initialization */
USBD_CDC_DeInit(pdev, cfgidx);
return USBD_OK;
}
Тут все просто: инициализируем (деинициализируем) оба класса. Вызываемые функции сами займутся созданием/удалением своих конечных точек.
Пожалуй самой сложной функцией будет Setup.
/**
* @brief USBD_MSC_CDC_Setup
* Handle the MSC+CDC specific requests
* @param pdev: instance
* @param req: usb requests
* @retval status
*/
static uint8_t USBD_MSC_CDC_Setup (USBD_HandleTypeDef *pdev, USBD_SetupReqTypedef *req)
{
// Route requests to MSC interface or its endpoints to MSC class implementaion
if(((req->bmRequest & USB_REQ_RECIPIENT_MASK) == USB_REQ_RECIPIENT_INTERFACE && req->wIndex == MSC_INTERFACE_IDX) ||
((req->bmRequest & USB_REQ_RECIPIENT_MASK) == USB_REQ_RECIPIENT_ENDPOINT && ((req->wIndex & 0x7F) == MSC_EP_IDX)))
{
return USBD_MSC_Setup(pdev, req);
}
return USBD_CDC_Setup(pdev, req);
}
Это коллбек на один из стандартных запросов по шине USB, но этот запрос очень многогранный. Это может быть как получение данных (get), так и установка (Set). Это может быть запрос к устройству в целом, к одному из его интерфейсов или конечных точек. Также тут может приплыть как стандартный запрос, определенный базовой спецификацией USB, так и специфичный для определенного устройства или класса. Подробнее тут (Раздел “Пакет Setup”).
Из-за обилия разных случаев структура обработчика пакета Setup весьма сложна. Тут не получается написать один if или switch. В коде ядра USB обработка размазана по 3-4 большим функциям и в определенных случаях передается отдельному специализированному обработчику (коих там еще с десяток). Радует только то, что на уровень драйвера класса передается только незначительная часть запросов.
Я подсмотрел какие пакеты ходят через эту функцию и, похоже, можно ориентироваться по получателю. Если получатель пакета интерфейс — в поле wIndex будет номер интерфейса, если конечная точка, то в wIndex будет номер конечной точки. Исходя из этого перенаправляем запросы в соответствующий обработчик.
Кстати, чтобы это работало нужно не забыть поменять дефайн, определяющий количество интерфейсов, а то запрос просто не дойдет и срежется внутри ядра USB
#define USBD_MAX_NUM_INTERFACES 3
Коллбеками DataIn и DataOut все проще. Там есть номер конечной точки — по ней и определим куда запрос перенаправлять
/**
* @brief USBD_MSC_CDC_DataIn
* handle data IN Stage
* @param pdev: device instance
* @param epnum: endpoint index
* @retval status
*/
static uint8_t USBD_MSC_CDC_DataIn (USBD_HandleTypeDef *pdev,
uint8_t epnum)
{
if(epnum == MSC_EP_IDX)
return USBD_MSC_DataIn(pdev, epnum);
return USBD_CDC_DataIn(pdev, epnum);
}
/**
* @brief USBD_MSC_CDC_DataOut
* handle data OUT Stage
* @param pdev: device instance
* @param epnum: endpoint index
* @retval status
*/
static uint8_t USBD_MSC_CDC_DataOut (USBD_HandleTypeDef *pdev,
uint8_t epnum)
{
if(epnum == MSC_EP_IDX)
return USBD_MSC_DataOut(pdev, epnum);
return USBD_CDC_DataOut(pdev, epnum);
}
Обратите внимание, что флаг направления передачи в номере конечной точки не используется. Т.е. даже если некоторые функции используют MSC_IN_EP (0x81), то в этой функции нужно использовать MSC_EP_IDX (0x01).
Иногда данные приходят в нулевую конечную точку и для этого есть специальный коллбек. Я не знаю что бы я делал, если бы оба класса (и CDC и MSC) имели обработчики на этот случай – в таком запросе не указан интерфейс или номер конечной точки. Было бы невозможно понять кому адресован запрос. Благо такой запрос умеет обрабатывать только класс CDC – вот ему и отправим
/**
* @brief USBD_MSC_CDC_EP0_RxReady
* handle EP0 Rx Ready event
* @param pdev: device instance
* @retval status
*/
static uint8_t USBD_MSC_CDC_EP0_RxReady (USBD_HandleTypeDef *pdev)
{
return USBD_CDC_EP0_RxReady(pdev);
}
Больше у нас не будет нетривиальных обработчиков. Есть еще парочка геттеров для дескрипторов, но их код стандартный и не представляет интереса. Заполним «таблицу виртуальных функций»
USBD_ClassTypeDef USBD_MSC_CDC_ClassDriver =
{
USBD_MSC_CDC_Init,
USBD_MSC_CDC_DeInit,
USBD_MSC_CDC_Setup,
NULL, //USBD_MSC_CDC_EP0_TxReady,
USBD_MSC_CDC_EP0_RxReady,
USBD_MSC_CDC_DataIn,
USBD_MSC_CDC_DataOut,
NULL, //USBD_MSC_CDC_SOF,
NULL, //USBD_MSC_CDC_IsoINIncomplete,
NULL, //USBD_MSC_CDC_IsoOutIncomplete,
USBD_MSC_CDC_GetCfgDesc,
USBD_MSC_CDC_GetCfgDesc,
USBD_MSC_CDC_GetCfgDesc,
USBD_MSC_CDC_GetDeviceQualifierDesc,
};
Теперь код инициализации
USBD_Init(&hUsbDeviceFS, &FS_Desc, 0);
USBD_RegisterClass(&hUsbDeviceFS, &USBD_MSC_CDC_ClassDriver);
USBD_CDC_RegisterInterface(&hUsbDeviceFS, &USBD_Interface_fops_FS);
USBD_MSC_RegisterStorage(&hUsbDeviceFS, &SdMscDriver);
USBD_Start(&hUsbDeviceFS);
Инициализируем USB ядро, устанавливаем ему наш драйвер класса и настраиваем вторичные интерфейсы. Все? Нет не все. В таком виде оно не запустится.
Дело вот в чем. Каждый класс имеет некоторое количество приватных данных – состояние драйвера, какие то переменные, которые должны быть доступны в разных функциях драйвера. Причем это не могут быть просто глобальные переменные – они привязаны к конкретному USB устройству (иначе невозможно было бы оперировать сразу с несколькими устройствами, если такое необходимо). Поэтому в хендле USB завели сразу несколько полей для такого случая
/* USB Device handle structure */
typedef struct _USBD_HandleTypeDef
{
...
void *pClassData;
void *pUserData;
void *pData;
} USBD_HandleTypeDef;
Проблема в том, что каждый класс считает эти поля своей собственностью и цепляет туда свою структуру.
Решать это можно несколькими способами. Товарищи отсюда вообще затолкали в свою реализацию класса весь код из обоих драйверов (CDC и MSC) чтобы на ходу разбираться что к чему. Другой подход в том, что в эти поля класть структуры, в которых есть место для данных обоих классов. Тут частично использован этот подход, вдобавок еще часть данных перенесена в глобальные переменные (что ок, если у нас только один USB порт)
Мы, пожалуй, пойдем путем попроще. Если драйверы классов хотят эксклюзивных полей – дадим им эти поля
typedef struct _USBD_HandleTypeDef
{
...
USBD_MSC_BOT_HandleTypeDef *pClassDataMSC;
const USBD_StorageTypeDef *pClassSpecificInterfaceMSC;
USBD_CDC_HandleTypeDef *pClassDataCDC;
const USBD_CDC_ItfTypeDef *pClassSpecificInterfaceCDC;
PCD_HandleTypeDef *pPCDHandle;
} USBD_HandleTypeDef;
Во-первых, я дал каждому классу свои поля – пусть терзают их как хотят. Во-вторых, я назвал эти поля согласно тому что в них реально лежит – никакая там не UserData, а указатель на интерфейс.
Конечно же на плюсах это было бы красивее и элегантнее (при том же расходе памяти и проца). но и на C можно сделать по человечески. Раз уж я запустил свои ручонки в структуру хендла, то и поменял непонятные void * на человеческие типы (кстати, поле void * pData теперь оно по человечески называется pPCDHandle с соответствующим типом). И const тоже расставил где надо. Пришлось, правда, повозиться с forward declarations.
Про организацию проекта. В некоторых IDE проект может быть построен следующим образом. Библиотека USB и исходники драйверов классов поставляются вместе с STM32 Cube, но часть файлов предлагается написать пользователю. Может так случится, что библиотека лежит где нибудь в общей локации и используется несколькими проектами. Стоит понимать, что я сейчас мы меняем код библиотеки USB и потому лучше иметь собственную копию, чтобы никому не мешать.
Конечно же переименования полей должны отразится в коде драйвера. Но тут как раз все просто – контекстная замена решает проблему.
Тут главное не переборщить. Я вот менял руками, просматривая каждое использование. Там я нашел «баг» в коде, зачинил его а потом 3 дня дебажился в попытке понять почему оно не работает.
USBD_StatusTypeDef USBD_LL_Reset(USBD_HandleTypeDef *pdev)
{
...
if (pdev->pClassData)
pdev->pClass->DeInit(pdev, pdev->dev_config);
...
}
Вот тут было все правильно – проверяем pClassData, а обращаемся к pClass. Если «починить» (проверять pClass), то работать не будет. Т.е. pClassData является своеобразным маркером того, что класс проинициализирован.
Возвращаясь к нашему драйверу. Поскольку Init() инициализирует обе переменные pClassDataXXX, то в этом коде можно проверять любую.
Финальный штрих – распределение PMA буферов
HAL_PCDEx_PMAConfig(pdev->pPCDHandle , 0x00 , PCD_SNG_BUF, 0x20);
HAL_PCDEx_PMAConfig(pdev->pPCDHandle , 0x80 , PCD_SNG_BUF, 0x60);
HAL_PCDEx_PMAConfig(pdev->pPCDHandle , MSC_IN_EP, PCD_SNG_BUF, 0xA0);
HAL_PCDEx_PMAConfig(pdev->pPCDHandle , MSC_OUT_EP, PCD_SNG_BUF, 0xE0);
HAL_PCDEx_PMAConfig(pdev->pPCDHandle, CDC_CMD_EP, PCD_SNG_BUF, 0x100);
HAL_PCDEx_PMAConfig(pdev->pPCDHandle, CDC_IN_EP, PCD_SNG_BUF, 0x140);
HAL_PCDEx_PMAConfig(pdev->pPCDHandle, CDC_OUT_EP, PCD_SNG_BUF, 0x180);
Для наших конечных точек потребуется 7 буферов — 2 на нулевую конечную точку (точку управления), 2 на MSC и 3 на CDC. Но самое интересное тут — начальные адреса (последний параметр). По непонятной причине этот нюанс тщательно обходится всеми туториалами. В даташите написано про распределение буферов в PMA и как это выглядит на на уровне регистров, но вот как пользоваться соответствующими функциями из HAL информации нет. Восполним этот пробел.
Итак. У контроллера есть специальная память — PMA (Packet Memory Area). Это такая память куда программа может записать данные, а USB периферия их прочитать (и наоборот). Память эта заранее не распределена, т.к. разные конечные точки могут быть настроены на разный размер пакета. Поэтому существует таблица BTABLE в которой указано где какой буфер размещается. Причем сама эта таблица также размещается в PMA. Таблицу можно двигать и размещать в любом месте PMA, но HAL умеет ее размещать только в самом начале.
Картинка из Reference Manual микроконтроллеров серии STM32F103
Итак, как же высчитать смещения буферов? Размер таблицы напрямую зависит от количества используемых конечных точек. Каждая конечная точка в таблице представлена записью из 4 16-битных значений (по 2 на прием и 2 на передачу, даже если одно из направлений не используется). У нас используется 4 конечных точки — нулевая, MSC и две для CDC (не путайте с количеством буферов — у нас их 7 — по два на конечную точку, но одна точка однонаправленная, поэтому у нее только один буфер). Значит размер таблицы будет 4 точки * 4 записи * 2 байта = 32 байта.
Как я уже сказал HAL умеет располагать только вначале PMA области. Значит первый буфер мы можем расположить только по смещению 0x20 (32 байта — размер таблицы). Буферы для конечных точек можно размещать где угодно в PMA памяти, лишь бы они не налазили друг на друга. Каждая конечная точка определяет максимальный размер пакета, который она готова обрабатывать, буфер должен быть равен или больше этого размера.
Я расположил буфера с шагом 64 байта (максимальный рекомендуемый размер буфера для устройств USB Full Speed), но для некоторых конечных точек можно было бы и меньше. Так по управляющей CDC конечной точке много данных не бегает (CDC_CMD_PACKET_SIZE равно 8 байт), поэтому и буфер можно делать всего на 8 байт. Впрочем, мне было не жалко и 32 байт — просто чтобы круглые цифры получались.
Пора компилировать и запускать. Моя винда сразу определила само устройство, увидела также и 2 составляющие. Это хорошая новость. Но есть и плохая. Если Mass Storage устройство определилось сразу, то CDC — нет.
Не беда — нужно просто подсунуть винде правильный драйвер. Вообще-то устройство стандартное и специальный драйвер не нужен. Достаточно просто связать это устройство со стандартным драйвером (в нашем случае это будет usbser.sys)
На самом деле я в этой кухне не очень разбираюсь. По идее нужно скачать STMicroelectronics Virtual COM Port драйвер с сайта ST. Драйвер устанавливается в C:Program Files (x86)STMicroelectronicsSoftwareVirtual comport driver, а внутри есть файлик stmcdc.inf — вот он то нам и нужен. В этом файле в двух секциях есть строка вида
%DESCRIPTION%=DriverInstall,USBVID_0483&PID_5740
Вот она то и связывает наш VID/PID c драйвером устройства. Только этого мало — нужно еще указать номер интерфейса, который управляет CDC. В моем случае это первый интерфейс (нулевой отвечает за MSC). Для этого строка должна выглядеть так
%DESCRIPTION%=DriverInstall,USBVID_0483&PID_5741&MI_01
На самом деле оригинальную строку можно не менять, а просто
добавлять строки в соответствующие секции.
После всех приготовлений находим нерабочее устройство в списке устройств, просим обновить драйвер, указываем директорию где лежит inf файл и вуаля — драйвер установлен. Винда сама присвоит этому устройство имя COMxx — можно брать любимую терминалку и открывать этот COM порт.
С линуксом все проще — там все заводится без танцев с бубном драйверами.
Заключение
На некоторых форумах видел сообщения вроде “как все в этом USB сложно, какие-то драйверы… Я щас лучше на регистрах нафигачу”. Ребят, не все так просто. Уровень регистров это, наверное, самая простая часть. Но помимо нее есть огромный пласт логики, которую должно реализовывать устройство. И вот тут уже без знаний протоколов и многих сотен страниц спецификаций никак.
Но не все так плохо. Люди уже позаботились и написали всю логику. В большинстве случаев остается только подставить нужные значения и подправить некоторые параметры. Да, библиотека от ST — тот еще монстр. Но после вдумчивого прочтения USB In A Nutshell, парочки спецификаций конкретного класса устройств и работы со сниффером многие вещи становятся на свои места. Библиотека начинает выглядеть более-менее стройно. Можно даже сравнительно небольшими усилиями сделать кастомный драйвер класса, что мы с успехом и сделали.
Я делал реализацию композитного CDC+MSC устройства, но примерно такой же подход можно применить и для других комбинаций — CDC+HID, MSC+Audio, CDC+MSC+HID и других. Моя реализация предназначена для работы на микроконтроллерах серии STM32F103, но сам принцип может быть адаптирован и для других микроконтроллеров (в т.ч. и не STM32).
В этой статье я не ставил себе задачу рассказать как работает USB во всех деталях — во-первых есть статьи и книги, которые рассказывают это лучше (я затронул лишь малую часть), а во-вторых очень много вещей лучше черпать из первоисточников (спецификаций).
Вместо пересказа спецификаций я попробовал описать как работает реализация USB стека от ST. Также я постарался обратить внимание на особые моменты и рассказать почему делается именно так.
Я долго сомневался ставить ли галочку “Tutorial”. С одной стороны я даю рекомендации и пошаговые инструкции, обращаю внимание на особые моменты и даю ссылки на первоисточники. С другой стороны я не могу предоставить готовую библиотеку для скачивания и встраивания в свои проекты.
Дело в том, что в процессе работы над своим проектом я хорошенько поработал напильником, лобзиком и другими инструментами над этой библиотекой. Я выкинул много кода, который в моем устройстве не нужен, часть поменял, починил некоторые вещи, которые мне не нравились. Теперь библиотека USB весьма серьезно отличается от той, что выложена на сайте ST. Некоторые из изменений специфичны для моего проекта и могут не подойти для других ситуаций. Впрочем, добро пожаловать в мой репозиторий — изучайте, копируйте к себе, задавайте вопросы, предлагайте улучшения.
Напоследок хочу высказать благодарность всем тем, кто мне так или иначе помогал с моей реализацией. Спасибо, ребята!
Автор: Александр Маслюченко