Прошло более полутора лет с последней статьи, посвященной применению современного C++ при разработке программ для микроконтроллеров, а именно USB. За это время удалось покрыть USB OTG FS, а также еще один класс устройств - Mass Storage. Среди изобилия способов организовать обмен данными между устройством и хостом остановился на связке SCSI поверх транспорта Bulk-Only (он же Bulk/Bulk/Bulk), так как, насколько удалось понять, эту пару можно назвать наиболее популярной, а также честно признаюсь, что шел по стопам уважаемого @COKPOWEHEU, а именно его материала USB на регистрах: bulk endpoint на примере Mass Storage. В нашем случае устройство класса MSC должно поддерживать один интерфейс с двумя конечными точками типа Bulk. Интерфейс также должен содержать один или несколько Logical Unit, которые для операционной системы являются логическими устройствами в составе одного физического. Таким образом в очередной раз мы получаем иерархическую структуру, схематично представленную на рисунке 1. Как можно заметить, интерфейс и все логические устройства разделяют между собой две конечные точки, что требует обеспечить диспетчеризацию запросов от хоста на уровне самого интерфейса. Общий порядок обмена приведен на рисунке 2. Очередной этап обмена данными начинается с передачи от хоста к устройству команды, которая задается 31-байтовой структурой CBW (Command Wrapper Block), её описание представлено в таблице. Размер (байтов) Название поля Описание поля 4 dCBWSignature Магическое число 0x43425355 (оно же 'USBC'). 4 dCBWTag Идентификатор запроса (должен совпадать с одноименным полем в структуре ответа). 4 dCBWDataTransferLength Максимальный объем передаваемых данных          (OUT или IN). 1 bmCBWFlags Направление передачи данных. Используется только старший (седьмой) бит. Если bmCBWFlags[7:7] = 0, то передача OUT, иначе IN. 1 bCBWLUN Номер LUN, которому адресована команда. Используются только младшие 4 бита (bCBWLUN[0:3]). 1 bCBWCBLength Длина команды в блоке CBWCB. Используются только младшие 5 бит (bCBWCBLength[0:4]). 16 CBWCB[16] Блок команды (формат зависит от конкретной команды). Если команда подразумевает передачу данных (OUT или IN), то устройство (точнее соответствующие Logical Unit) должно быть готов к приему или передаче. После нее (или сразу после получения CBW, если команда не подразумевает дополнительный обмен), устройство возвращает хосту ответ, который задается 5-байтовой структурой CSW (Command Status Wrapper), описание которой приведено в таблице. Размер (байтов) Название поля Описание поля 4 dCSWSignature Магическое число 0x53425355 (оно же 'USBS'). 4 dCSWTag Идентификатор соответствующего запроса (из принятой структуры CBW). 4 dCSWDataResidue Для передачи OUT поле содержит разницу между dCBWDataTransferLength и реально обработанных устройством данных (по документации я не понял, что считается "обработанными" - просто принятые или которые были "полезны"). Для передачи IN поле содержит разницу между dCBWDataTransferLength (ожидаемым объемом) и реально переданным количеством байтов. 1 bCSWStatus Статус команды. 0 - успешное выполнение, 1 - ошибка исполнения, 2 - ошибка фазы (как я понял, означает нарушение последовательности действий со стороны хоста). Таким образом, как и сказано в статье @COKPOWEHEU, первая посылка идет от хоста к устройству (блок CBW), вторая (при наличии) - либо от хоста, либо от устройства (в зависимости от команды), и третья - от устройства (блок CSW). На рисунке 3 представлен фрагмент окна Wireshark с командой чтения, демонстрирующий порядок пересылки пакетов. Базовый интерфейс должен выполнять две функции: Обработка общих команд, адресованных конкретно интерфейсу. Таких для интерфейса SCSI предусмотрено две: Bulk-Only Mass Storage Reset (значение bRequest в пакете Setup равно 255) и Get Max LUN (значение bRequest в пакете Setup равно 254). На первый из них, насколько я понял, можно особо не реагировать, на второй же нужно вернуть номер последнего LUN, Logical Unit-ы должны начинаться с нулевого и идти по порядку. Обрабатывать OUT-пакеты от хоста и диспетчеризовать запросы по соответствующим Logical Unit-ам. Если с первым пунктом всё понятно, то реализация второго не совсем очевидна, примерная блок-схема соответствующего метода представлена на рисунке 4 (прошу прощения за мои блок-схемы). Рассмотрим фрагменты исходного кода соответствующего интерфейса. Класс является шаблонным и производным от базового класса Interface: Ключевыми элементами объявления класса является variadic-параметр Luns, а также два constexpr-массива с указателями на обработчики OUT-пакетов с данными и обработчики команд соответственно, причем "базовый" класс ScsiLun расширяется за счет пользовательских типов, что является некоторым аналогом виртуальности в нешаблонном программировании на C++. Реализация метода обработчика OUT-пакетов не так интересна, поэтому спрятана под спойлер.  Поскольку реальным хранилищем данных может быть все, что угодно (RAM, Flash, EEPROM, sd-карта), то код доступа непосредственно к хранилищу унифицировать невозможно. Итогом размышлений об архитектуре соответствующих классов стало такое решение: главный шаблонный класс, реализующий обработку команд и формирующий блок CSW, который в качестве шаблонного аргумента принимает тип, реализующий четыре метода (в дальнейшем список можно расширить): GetLbaSize - возвращает размер блока LBA; GetLbaCount- возвращает количество блоков LBA; Read10Handler - обработчик команды READ (10); Write10Handler - обработчик команды WRITE (10). Класс ScsiLun, который расширяется переданными пользователем типами Luns содержит, по сути, главный обработчик команд. Его код представлен под спойлером. Магические массивы взял из статьи @COKPOWEHEU.  В итоге пользователю остается предоставить только размер хранилища (размер блока + их количество), а также обеспечить поддержку непосредственно чтения и записи. Для проверки работоспособности кода внедрен наиболее примитивный вариант Logical Unit с хранилищем в оперативной памяти. Ниже представлены определения методов  - обработчиков команд READ (10) и WRITE (10), а также обработчик принятия пакета данных OUT, они примитивны.  Экспериментировал на контроллере Stm32f401ccu6, который предлагает 64Кб RAM, чего, в принципе, хватает для носителя с файловой системой FAT 12. При подключении устройства к компьютеру Windows оповещает, что носитель не отформатирован и предлагает это сделать (см. рисунок 5), однако для FAT 16 места недостаточно и можно воспользоваться утилитой mkdosfs, как показано на рисунке 6. После форматирования окно свойств выглядит так, как на рисунке 7, а с носителем штатными способами можно осуществлять операции записи и чтения. Для измерения скорости можно объявить LUN объема гораздо большего, чем есть на самом деле памяти, а все старшие LBA переадресовать на последний реально доступный. Таким образом для операционной системы действительно доступно начало носителя (как раз для служебных данных файловой системы), а операции чтения и записи больших файлов будут приводить соответственно к чтению/перезаписи одного и того же участка памяти микроконтроллера. Для измерения скорости такой подход годится, так как данные действительно пересылаются по шине USB (более того, происходит реальный доступ на чтение/запись к памяти микроконтроллера). Запись большого файла показала достаточно низкую скорость (см. рисунок 8), но этот факт я связываю с некачественной реализацией USB OTG в целом (например, для F0/F1 удалось реализовать двойную буферизацию, для OTG пока нет). Проверить чтение обычным копированием у меня не получилось (оно выполнялось мгновенно, видимо, Windows закешировала скопированный ранее файл и реального чтения не происходило), поэтому взял первую попавшуюся программу проверки носителей, её скриншот приведен на рисунке 9. Результат чуть лучше, но все равно примерно вдвое меньше предела в 12,5 Мбит/с. Наличие шаблонного ядра USB в очередной раз позволило с минимальными усилиями добавить в библиотеку новый класс. Стоит заметить, что SCSI оказался весьма объемным и сложным для понимания, однако все работает даже если на многие сложность закрыть глаза и возвращать "магию" (знаю, что это не есть хорошо, но радует, что такая возможность в принципе есть). Скорость записи, как и чтения, оставляют желать лучшего, но это проблема конкретной реализации USB стека для контроллеров с OTG. В репозитории проекта можно посмотреть полный код примера и реализации классов, относящихся к MSC. Читая тематические статьи на хабре обнаружил, что значительная часть из них посвящена разработке составных (композитных) устройств, поэтому и я решил кратко рассмотреть этот вопрос. На отдельную статью это не тянет, поэтому будет "бонусом". Одной из основных идей построения стека USB на шаблонах была максимальная модульность, когда из элементов иерархии USB-устройства можно легко собрать любую конфигурацию. Считаю, что в целом мне это удалось, поэтому добавить в код произвольного USB-устройства еще один интерфейс можно просто добавлением нескольких строк кода. Например, добавим к нашему запоминающему устройству еще и HID. Ниже представлен фрагмент исходного кода проекта запоминающего устройства, а именно объявление конечных точек, интерфейса и конфигурации:  Из примера HID-устройства возьмем объявления report-а, дескриптора, и единственной конечной точки:  Учитывая, что составное устройство - это всего лишь устройство с несколькими интерфейсами, произведем композицию двух примеров:  После прошивки и подключения устройства в системе появились запоминающее устройство и HID-устройство, как показано на рисунке 10. Благодаря шаблонам создание композитного устройства по сложности абсолютно не отличается от создания обычного, потому что формирование дескриптора конфигурации (а мне показалось, что именно дескриптору в соответствующих статьях уделено значительное внимание) досталось "бесплатно".