Pull to refresh

Stm32 + USB на шаблонах C++. Продолжение'. Делаем MSC

Reading time12 min
Views5.8K

Прошло более полутора лет с последней статьи, посвященной применению современного C++ при разработке программ для микроконтроллеров, а именно USB. За это время удалось покрыть USB OTG FS, а также еще один класс устройств - Mass Storage.

Среди изобилия способов организовать обмен данными между устройством и хостом остановился на связке SCSI поверх транспорта Bulk-Only (он же Bulk/Bulk/Bulk), так как, насколько удалось понять, эту пару можно назвать наиболее популярной, а также честно признаюсь, что шел по стопам уважаемого @COKPOWEHEU, а именно его материала USB на регистрах: bulk endpoint на примере Mass Storage.

Интерфейсы

В нашем случае устройство класса MSC должно поддерживать один интерфейс с двумя конечными точками типа Bulk. Интерфейс также должен содержать один или несколько Logical Unit, которые для операционной системы являются логическими устройствами в составе одного физического.

Таким образом в очередной раз мы получаем иерархическую структуру, схематично представленную на рисунке 1.

Рисунок 1. Структура USB-устройства с интерфейсом SCSI.
Рисунок 1. Структура USB-устройства с интерфейсом SCSI.

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

Запросы интерфейса SCSI

Общий порядок обмена приведен на рисунке 2.

Рисунок 2. Обмен данными SCSI.
Рисунок 2. Обмен данными SCSI.

Очередной этап обмена данными начинается с передачи от хоста к устройству команды, которая задается 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 с командой чтения, демонстрирующий порядок пересылки пакетов.

Рисунок 3. Перехват команды на чтение READ (10).
Рисунок 3. Перехват команды на чтение READ (10).

Интерфейс SCSI

Базовый интерфейс должен выполнять две функции:

  1. Обработка общих команд, адресованных конкретно интерфейсу. Таких для интерфейса SCSI предусмотрено две: Bulk-Only Mass Storage Reset (значение bRequest в пакете Setup равно 255) и Get Max LUN (значение bRequest в пакете Setup равно 254). На первый из них, насколько я понял, можно особо не реагировать, на второй же нужно вернуть номер последнего LUN, Logical Unit-ы должны начинаться с нулевого и идти по порядку.

  2. Обрабатывать OUT-пакеты от хоста и диспетчеризовать запросы по соответствующим Logical Unit-ам.

Если с первым пунктом всё понятно, то реализация второго не совсем очевидна, примерная блок-схема соответствующего метода представлена на рисунке 4 (прошу прощения за мои блок-схемы).

Рисунок 4. Блок-схема обработчика OUT-пакета интерфейса SCSI.
Рисунок 4. Блок-схема обработчика OUT-пакета интерфейса SCSI.

Рассмотрим фрагменты исходного кода соответствующего интерфейса. Класс является шаблонным и производным от базового класса Interface:

template <uint8_t _Number, uint8_t _AlternateSetting, typename _Ep0, typename _OutEp,
         typename _InEp, typename... _Luns>
class ScsiBulkInterface : public Interface<_Number, _AlternateSetting,
         InterfaceClass::Storage, static_cast<uint8_t>(MscSubclass::Scsi),
         static_cast<uint8_t>(MscProtocol::Bbb), _Ep0, _OutEp, _InEp>
{     
  using Base = Interface<_Number, _AlternateSetting, InterfaceClass::Storage, static_cast<uint8_t>(MscSubclass::Scsi), static_cast<uint8_t>(MscProtocol::Bbb), _Ep0, _OutEp, _InEp>;

  static constexpr std::add_pointer_t<bool(void* buffer, uint16_t size)> _lunRxHandlers[]
    = {_Luns::RxHandler...};
  static constexpr std::add_pointer_t<bool(const BulkOnlyCBW& cbw, BulkOnlyCSW& csw, InTransferCallback callback)> _lunCommandHandlers[]
    = {(ScsiLun<_Luns>::template CommandHandler<_InEp>)...};
  ...

Ключевыми элементами объявления класса является variadic-параметр Luns, а также два constexpr-массива с указателями на обработчики OUT-пакетов с данными и обработчики команд соответственно, причем "базовый" класс ScsiLun расширяется за счет пользовательских типов, что является некоторым аналогом виртуальности в нешаблонном программировании на C++.

Реализация метода обработчика OUT-пакетов не так интересна, поэтому спрятана под спойлер.

Обработчик OUT-пакетов интерфейса SCSI
static void HandleRx(void* data, uint16_t size)
{
  static BulkOnlyCBW request;
  static BulkOnlyCSW response;
  static uint8_t cbwBytesReceived = 0;
  static bool needReceive = false;

  if(cbwBytesReceived < sizeof(BulkOnlyCBW)) {
    CopyFromUsbPma(reinterpret_cast<uint8_t*>(&request) + cbwBytesReceived, data, size);

    cbwBytesReceived += size;

    if(cbwBytesReceived == sizeof(BulkOnlyCBW)) {
      needReceive = _lunCommandHandlers[request.Lun](request, response, [](){
        cbwBytesReceived = 0;
        _InEp::SendData(&response, sizeof(response));
      });
    } else {
      return;
    }
  } else if (needReceive) {
    needReceive = _lunRxHandlers[request.Lun](data, size);
    if(!needReceive) {
      cbwBytesReceived = 0;
      _InEp::SendData(&response, sizeof(response));
    }
  }
}

Класс Lun

Поскольку реальным хранилищем данных может быть все, что угодно (RAM, Flash, EEPROM, sd-карта), то код доступа непосредственно к хранилищу унифицировать невозможно. Итогом размышлений об архитектуре соответствующих классов стало такое решение: главный шаблонный класс, реализующий обработку команд и формирующий блок CSW, который в качестве шаблонного аргумента принимает тип, реализующий четыре метода (в дальнейшем список можно расширить):

  • GetLbaSize - возвращает размер блока LBA;

  • GetLbaCount- возвращает количество блоков LBA;

  • Read10Handler - обработчик команды READ (10);

  • Write10Handler - обработчик команды WRITE (10).

Класс ScsiLun, который расширяется переданными пользователем типами Luns содержит, по сути, главный обработчик команд. Его код представлен под спойлером. Магические массивы взял из статьи @COKPOWEHEU.

Базовый (хотя технически наоборот, это эмуляция виртуальности) класс ScsiLun
template<typename _LunSpecialization>
class ScsiLun : public ScsiLunBase
{
public:
  template<typename _InEp>
  static bool CommandHandler(const BulkOnlyCBW& cbw, BulkOnlyCSW& csw, InTransferCallback callback)
  {
    csw.Tag = cbw.Tag;
    csw.Status = BulkOnlyCSW::CswStatus::Passed;
    csw.DataResidue = 0;

    switch (static_cast<ScsiCommand>(cbw.CommandBlock[0]))
    {
    case ScsiCommand::Inquiry:
      if(cbw.CommandBlock[1] & 0x01) {
        _InEp::SendData(inquiry_page00_data, cbw.DataLength < sizeof(inquiry_page00_data) ? cbw.DataLength : sizeof(inquiry_page00_data), callback);
      } else {
        _InEp::SendData(inquiry_response, cbw.DataLength < sizeof(inquiry_response) ? cbw.DataLength : sizeof(inquiry_response), callback);
      }
      break;
    case ScsiCommand::MmcReadFormatCapacity: {
      constexpr uint8_t buffer[] = { 0, 0, 0, 8,
        (_LunSpecialization::GetLbaCount() >> 24) & 0xff,
        (_LunSpecialization::GetLbaCount() >> 16) & 0xff,
        (_LunSpecialization::GetLbaCount() >> 8) & 0xff,
        (_LunSpecialization::GetLbaCount() >> 0) & 0xff,

        0b10, // formatted media
        (_LunSpecialization::GetLbaSize() >> 16) & 0xff,
        (_LunSpecialization::GetLbaSize() >> 8) & 0xff,
        (_LunSpecialization::GetLbaSize() >> 0) & 0xff,
      };
      _InEp::SendData(buffer, sizeof(buffer), callback);
      break;
    }
    case ScsiCommand::ReadCapacity: {
      uint32_t buffer[] = { ConvertLeBe(_LunSpecialization::GetLbaCount() - 1), ConvertLeBe(_LunSpecialization::GetLbaSize()) };

      _InEp::SendData(buffer, sizeof(buffer), callback);
      break;
    }
    case ScsiCommand::ModeSense6: {
      uint8_t buffer[] = {3, 0, 0, 0};
      csw.DataResidue = cbw.DataLength - sizeof(buffer);
      _InEp::SendData(buffer, sizeof(buffer), callback);
      break;
    }
    case ScsiCommand::TestUnitReady : {
      callback();
      break;
    }
    case ScsiCommand::Read10: {
      uint32_t startLba = ConvertLeBe(reinterpret_cast<const ScsiReadWrite10Request*>(&cbw.CommandBlock[0])->BlockAddress);
      uint32_t lbaCount = ConvertLeBe(reinterpret_cast<const ScsiReadWrite10Request*>(&cbw.CommandBlock[0])->Length);

      _LunSpecialization::template Read10Handler<_InEp>(startLba, lbaCount, callback);
      break;
    }
    case ScsiCommand::Write10: {
      uint32_t startLba = ConvertLeBe(reinterpret_cast<const ScsiReadWrite10Request*>(&cbw.CommandBlock[0])->BlockAddress);
      uint32_t lbaCount = ConvertLeBe(reinterpret_cast<const ScsiReadWrite10Request*>(&cbw.CommandBlock[0])->Length);

      return _LunSpecialization::Write10Handler(startLba, lbaCount);
    }
    case ScsiCommand::MmcStartStopUnit:
    case ScsiCommand::MmcPreventAllowRemoval:
      callback();
    default:
      break;
    }

    return false;
  }
};

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

Простейшее устройство класса Mass Storage

Для проверки работоспособности кода внедрен наиболее примитивный вариант Logical Unit с хранилищем в оперативной памяти. Ниже представлены определения методов - обработчиков команд READ (10) и WRITE (10), а также обработчик принятия пакета данных OUT, они примитивны.

Обработчики команд READ/WRITE и RxHandler
template<typename _InEp>
static void Read10Handler(uint32_t startLba, uint32_t lbaCount, InTransferCallback callback)
{
  _InEp::SendData(&_buffer[startLba * _LbaSize], lbaCount * _LbaSize, callback);
}
static bool Write10Handler(uint32_t startLba, uint32_t lbaCount)
{
  _rxAddress = startLba * _LbaSize;
  _rxBytesRemain = lbaCount * _LbaSize;
  
  return lbaCount > 0;
}
static bool RxHandler(void* data, uint16_t size)
{
  CopyFromUsbPma(&_buffer[_rxAddress], data, size);

  _rxAddress += size;
  _rxBytesRemain -= size;

  return _rxBytesRemain > 0;
}

Определение и форматирование устройства

Экспериментировал на контроллере Stm32f401ccu6, который предлагает 64Кб RAM, чего, в принципе, хватает для носителя с файловой системой FAT 12.

При подключении устройства к компьютеру Windows оповещает, что носитель не отформатирован и предлагает это сделать (см. рисунок 5), однако для FAT 16 места недостаточно и можно воспользоваться утилитой mkdosfs, как показано на рисунке 6.

Рисунок 5. Подключение носителя.
Рисунок 5. Подключение носителя.
Рисунок 6. Форматирование с помощью mkdosfs.
Рисунок 6. Форматирование с помощью mkdosfs.

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

Рисунок 7. Свойства носителя.
Рисунок 7. Свойства носителя.

Замеры скорости

Для измерения скорости можно объявить LUN объема гораздо большего, чем есть на самом деле памяти, а все старшие LBA переадресовать на последний реально доступный. Таким образом для операционной системы действительно доступно начало носителя (как раз для служебных данных файловой системы), а операции чтения и записи больших файлов будут приводить соответственно к чтению/перезаписи одного и того же участка памяти микроконтроллера. Для измерения скорости такой подход годится, так как данные действительно пересылаются по шине USB (более того, происходит реальный доступ на чтение/запись к памяти микроконтроллера).

Запись большого файла показала достаточно низкую скорость (см. рисунок 8), но этот факт я связываю с некачественной реализацией USB OTG в целом (например, для F0/F1 удалось реализовать двойную буферизацию, для OTG пока нет).

Рисунок 8. Запись крупного файла на носитель.
Рисунок 8. Запись крупного файла на носитель.

Проверить чтение обычным копированием у меня не получилось (оно выполнялось мгновенно, видимо, Windows закешировала скопированный ранее файл и реального чтения не происходило), поэтому взял первую попавшуюся программу проверки носителей, её скриншот приведен на рисунке 9. Результат чуть лучше, но все равно примерно вдвое меньше предела в 12,5 Мбит/с.

Рисунок 9. Тест на чтение.
Рисунок 9. Тест на чтение.

Заключение

Наличие шаблонного ядра USB в очередной раз позволило с минимальными усилиями добавить в библиотеку новый класс.

Стоит заметить, что SCSI оказался весьма объемным и сложным для понимания, однако все работает даже если на многие сложность закрыть глаза и возвращать "магию" (знаю, что это не есть хорошо, но радует, что такая возможность в принципе есть). Скорость записи, как и чтения, оставляют желать лучшего, но это проблема конкретной реализации USB стека для контроллеров с OTG.

В репозитории проекта можно посмотреть полный код примера и реализации классов, относящихся к MSC.

P.S. Композитное устройство

Читая тематические статьи на хабре обнаружил, что значительная часть из них посвящена разработке составных (композитных) устройств, поэтому и я решил кратко рассмотреть этот вопрос. На отдельную статью это не тянет, поэтому будет "бонусом".

Одной из основных идей построения стека USB на шаблонах была максимальная модульность, когда из элементов иерархии USB-устройства можно легко собрать любую конфигурацию. Считаю, что в целом мне это удалось, поэтому добавить в код произвольного USB-устройства еще один интерфейс можно просто добавлением нескольких строк кода. Например, добавим к нашему запоминающему устройству еще и HID.

Ниже представлен фрагмент исходного кода проекта запоминающего устройства, а именно объявление конечных точек, интерфейса и конфигурации:

Объявления класса Mass Storage
constexpr Zhele::TemplateUtils::fixed_string_16 Manufacturer(u"ZheleProduction");
constexpr Zhele::TemplateUtils::fixed_string_16 Product(u"MSDExample");
constexpr Zhele::TemplateUtils::fixed_string_16 Serial(u"88005553535");

using MscOutEpBase = OutEndpointBase<1, EndpointType::Bulk, 64, 0>;
using MscInEpBase = InEndpointWithoutZlpBase<2, EndpointType::Bulk, 64, 0>;

using EpInitializer = EndpointsInitializer<DefaultEp0, MscOutEpBase, MscInEpBase>;
using Ep0 = EpInitializer::ExtendEndpoint<DefaultEp0>;

using MscOutEp = EpInitializer::ExtendEndpoint<MscOutEpBase>;
using MscInEp = EpInitializer::ExtendEndpoint<MscInEpBase>;

using Lun0 = DefaultScsiLun<512, 120>;

using Scsi = ScsiBulkInterface<0, 0, Ep0, MscOutEp, MscInEp, Lun0>;

using Config = Configuration<0, 250, false, false, Scsi>;
using MyDevice = Device<0x0200, DeviceAndInterfaceClass::Storage, 0, 0, 0x0483, 0x5711, 0, Ep0, Config>;

Из примера HID-устройства возьмем объявления report-а, дескриптора, и единственной конечной точки:

Конечная точка, HID report и HID descriptor
using Report = HidReport<
        0x06, 0x00, 0xff,              // USAGE_PAGE (Generic Desktop)
        0x09, 0x01,                    // USAGE (Vendor Usage 1)
        0xa1, 0x01,                    // COLLECTION (Application)
        0x85, 0x01,                    //   REPORT_ID (1)
        0x09, 0x01,                    //   USAGE (Vendor Usage 1)
        0x15, 0x00,                    //   LOGICAL_MINIMUM (0)
        0x25, 0x01,                    //   LOGICAL_MAXIMUM (1)
        0x75, 0x08,                    //   REPORT_SIZE (8)
        0x95, 0x01,                    //   REPORT_COUNT (1)
        0xb1, 0x82,                    //   FEATURE (Data,Var,Abs,Vol)
        0x85, 0x01,                    //   REPORT_ID (1)
        0x09, 0x01,                    //   USAGE (Vendor Usage 1)
        0x91, 0x82,                    //   OUTPUT (Data,Var,Abs,Vol)
        0xc0                           // END_COLLECTION
    >;

using HidDesc = HidImpl<0x1001, Report>;

using LedsControlEpBase = OutEndpointBase<1, EndpointType::Interrupt, 4, 255>;
using EpInitializer = EndpointsInitializer<DefaultEp0, LedsControlEpBase>;

using Ep0 = EpInitializer::ExtendEndpoint<DefaultEp0>;
using LedsControlEp = EpInitializer::ExtendEndpoint<LedsControlEpBase>;

using Hid = HidInterface<0, 0, 0, 0, HidDesc, Ep0, LedsControlEp>;

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

Объявления элементов составного устройства
using Report = HidReport<
        0x06, 0x00, 0xff,              // USAGE_PAGE (Generic Desktop)
        0x09, 0x01,                    // USAGE (Vendor Usage 1)
        0xa1, 0x01,                    // COLLECTION (Application)
        0x85, 0x01,                    //   REPORT_ID (1)
        0x09, 0x01,                    //   USAGE (Vendor Usage 1)
        0x15, 0x00,                    //   LOGICAL_MINIMUM (0)
        0x25, 0x01,                    //   LOGICAL_MAXIMUM (1)
        0x75, 0x08,                    //   REPORT_SIZE (8)
        0x95, 0x01,                    //   REPORT_COUNT (1)
        0xb1, 0x82,                    //   FEATURE (Data,Var,Abs,Vol)
        0x85, 0x01,                    //   REPORT_ID (1)
        0x09, 0x01,                    //   USAGE (Vendor Usage 1)
        0x91, 0x82,                    //   OUTPUT (Data,Var,Abs,Vol)
        0xc0                           // END_COLLECTION
    >;

using HidDesc = HidImpl<0x1001, Report>;

// Конечные точки
using MscOutEpBase = OutEndpointBase<1, EndpointType::Bulk, 64, 0>;
using MscInEpBase = InEndpointWithoutZlpBase<2, EndpointType::Bulk, 64, 0>;
using LedsControlEpBase = OutEndpointBase<3, EndpointType::Interrupt, 4, 255>;

// Расширение конечных точек
using EpInitializer = EndpointsInitializer<DefaultEp0, MscOutEpBase, MscInEpBase, LedsControlEpBase>;
using Ep0 = EpInitializer::ExtendEndpoint<DefaultEp0>;
using MscOutEp = EpInitializer::ExtendEndpoint<MscOutEpBase>;
using MscInEp = EpInitializer::ExtendEndpoint<MscInEpBase>;
using LedsControlEp = EpInitializer::ExtendEndpoint<LedsControlEpBase>;

// Интерфейс MSC (номер 0)
using Lun0 = DefaultScsiLun<512, 120>;
using Scsi = ScsiBulkInterface<0, 0, Ep0, MscOutEp, MscInEp, Lun0>;
// Интерфейс HID (номер 1)
using Hid = HidInterface<1, 0, 0, 0, HidDesc, Ep0, LedsControlEp>;

using Config = Configuration<0, 250, false, false, Scsi, Hid>; // Просто вписываем два интерфейса
using MyDevice = Device<0x0200, DeviceAndInterfaceClass::InterfaceSpecified, 0x02, 0, 0x0483, 0x5711, 0, Ep0, Config>;

После прошивки и подключения устройства в системе появились запоминающее устройство и HID-устройство, как показано на рисунке 10.

Рисунок 10. Композитное устройство.
Рисунок 10. Композитное устройство.

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

Tags:
Hubs:
+13
Comments10

Articles