*За прикольную идею с изолентой спасибо @grafalex

Никто не любит USB

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

  • @jaiprakashнапомнил, что обязательное для USB-устройства значение VID нужно покупать за большие деньги.

  • Отсутствие в большинстве проектов необходимости высокоскоростной передачи данных.

  • Высокая сложность самого стандарта и разработки по сравнению с привычным многим интерфейсом UART. Дешевле добавить в устройство готовый USB<->UART переходник.

  • Отсутствие навыков разработки драйверов для ОС Windows/Linux.

В итоге, разработчики, в основном, предпочитают использовать UART (через аппаратный конвертер или, максимум, путем создания VCP-девайса, код которого успешно генерирует CubeMX). Я решил попытаться разобраться с USB хотя бы на базовом уровне, продолжив линию применения шаблонов языка C++. В этом посте описан примененный способ распределения ресурсов (а именно буферной памяти и регистров) между конечными точками устройства.

Проблема дублирования

Основным элементом программы, реализующей USB-устройство, является Конечная точка (Endpoint). Хост обменивается данными с конкретной конечной точкой. Устройство обязано содержать конечную точку с номером 0, через которую происходит управление, запросы различных дескрипторов на этапе энумерации, команды назначения адреса, выбора конфигурации и все остальное управление. Более подробно с понятием конечных точек и в принципе базовыми знаниями USB можно ознакомиться в переводе "USB in NutShell" на ресурсе microsin (ребятам огромное спасибо за проделанный труд, очень полезное дело сделали).

Контроллеры Stm32F0/F1 содержат специальную область памяти - Packet Memory Area (PMA), в которой размещается таблица дескрипторов буферов пакетов конечных точек и сами буферы пакетов конечных точек. Все всех примерах низкоуровневой реализации USB-стека предлагается вручную распределять память, что как минимум не очень удобно, а также увеличивает риски допустить ошибку. Например, чтобы изменить размер буфера конечной точки K, необходимо "подвинуть" все смещения для конечных точек K+1, ... , N. (где N - общее количество конечных точек). Лично в себе я уверен: 100% рано или поздно ошибусь.

Стоит учитывать, что параметры конечных точек (включая размер буфера) известны на момент компиляции, что позволяет перенести распределение памяти из runtime в compile-time, это дает два основных преимущества:

  • Исключение дублирования. Указания размеров буферов достаточно для распределения смещений. Также отпадает в принципе необходимость ручной инициализации дескрипторов буферов пакетов (ADDRn_TX, COUNTn_TX, ADDRn_RX, COUNTn_RX), так как их значения можно вычислить на этапе компиляции, а в runtime достаточно просто записать в память.

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

Конечные точки и их параметры

Каждая конечная точка характеризуется следующими параметрами:

  1. Номер конечной точки (0..16).

  2. Тип конечной точки (Control, Interrupt, Bulk, Isochronous).

  3. Направление (In, Out).

  4. Максимальный размер пакета для передачи.

Все перечисленные параметры входят в дескриптор конечной точки, который отправляется на хост в процессе энумерации.

В рамках устройства для конечной точки также должны быть назначены:

  1. Регистр конечной точки (EPnR).

  2. Адрес дескриптора буфера пакетов.

  3. Адрес буфера пакетов (или двух буферов в случае двунаправленной конечной точки или конечной точки с двойной буферизацией).

И вот здесь возникает определенная сложность: перечисленные выше значения конечной точки N зависят от всех других конечных точек. Из различных вариантов, которые пришли в голову, остановился на следующей методике:

  1. Объявить все конечные точки с указанием номера, типа, направления и максимального размера пакета.

  2. Объявить класс менеджера конечных точек, передав все объявленные конечные точки.

  3. Получить от менеджера "расширенные" версии конечных точек.

Реализация менеджера конечных точек

В результате получился следующий класс:

template<typename... AllEndpoints,
  typename... BidirectionalAndBulkDoubleBufferedEndpoints,
  typename... RxEndpoints,
  typename... BulkDoubleBufferedTxEndpoints>
class EndpointsManagerBase<TypeList<AllEndpoints...>,
  TypeList<BidirectionalAndBulkDoubleBufferedEndpoints...>,
  TypeList<RxEndpoints...>,
  TypeList<BulkDoubleBufferedTxEndpoints...>>
{
  // Все конечные точки
  using AllEndpointsList = TypeList<AllEndpoints...>;
  /// Размер таблицы дескрипторов буферов пакетов 
  static const auto BdtSize = 8 * (EndpointEPRn<GetType_t<sizeof...(AllEndpoints) - 1, AllEndpointsList>, AllEndpointsList>::RegisterNumber + 1);
  /// Смещение буфера для указанной конечной точки
  template<typename Endpoint>
  static constexpr uint32_t BufferOffset = BdtSize + OffsetOfBuffer<TypeIndex<Endpoint, AllEndpointsList>::value, AllEndpointsList>::value;
  /// Смещение дескриптора для указанной конечной точки
  template<typename Endpoint>
  static constexpr uint32_t BdtCellOffset =
    EndpointEPRn<Endpoint, AllEndpointsList>::RegisterNumber * 8
      + (Endpoint::Type == EndpointType::Control
      || Endpoint::Type == EndpointType::ControlStatusOut
      || Endpoint::Type == EndpointType::BulkDoubleBuffered
      || Endpoint::Direction == EndpointDirection::Out
      || Endpoint::Direction == EndpointDirection::Bidirectional
        ? 0
        : 4);
  /// Базовый адрес буфера USB
  static const uint32_t BdtBase = PmaBufferBase;
public:
  /// "Расширяет" конечную точку
  template<typename Endpoint>
  using ExtendEndpoint = 
    typename Select<Endpoint::Type == EndpointType::Control || Endpoint::Type == EndpointType::ControlStatusOut,
    ControlEndpoint<Endpoint,
      typename EndpointEPRn<Endpoint, TypeList<AllEndpoints...>>::type,
      PmaBufferBase + BufferOffset<Endpoint>, // TxBuffer
      PmaBufferBase + BdtCellOffset<Endpoint> + 2, // TxCount
      PmaBufferBase + BufferOffset<Endpoint> + Endpoint::MaxPacketSize, // RxBuffer
      PmaBufferBase + BdtCellOffset<Endpoint> + 6>, //RxCount
    typename Select<Endpoint::Direction == EndpointDirection::Bidirectional,
    BidirectionalEndpoint<Endpoint,
      typename EndpointEPRn<Endpoint, TypeList<AllEndpoints...>>::type,
      PmaBufferBase + BufferOffset<Endpoint>, // TxBuffer
      PmaBufferBase + BdtCellOffset<Endpoint> + 2, // TxCount
      PmaBufferBase + BufferOffset<Endpoint> + Endpoint::MaxPacketSize, // RxBuffer
      PmaBufferBase + BdtCellOffset<Endpoint> + 6>, //RxCount
    ... // Каждый базовый тип расширяется в соответствующий расширенный
    void>::value>::value;

  static void Init()
  {
    memset(reinterpret_cast<void*>(BdtBase), 0x00, BdtSize);
    // Базовая инициализация всех конечных точек
    ((*(reinterpret_cast<uint16_t*>(BdtBase + BdtCellOffset<AllEndpoints>)) = BufferOffset<AllEndpoints>), ...);
    // Инициализация второго буфера для двунаправленных точек и точек с двойной буферизацией
    ((*(reinterpret_cast<uint16_t*>(BdtBase + BdtCellOffset<BidirectionalAndBulkDoubleBufferedEndpoints> + 4)) = (BufferOffset<BidirectionalAndBulkDoubleBufferedEndpoints> + BidirectionalAndBulkDoubleBufferedEndpoints::MaxPacketSize)), ...);
    // Инициализация COUNTn_RX для принимающих (Rx, Out) точек
    ((*(reinterpret_cast<uint16_t*>(BdtBase + BdtCellOffset<RxEndpoints> + 2)) = (RxEndpoints::MaxPacketSize <= 62
      ? (RxEndpoints::MaxPacketSize / 2) << 10
      : 0x8000 | (RxEndpoints::MaxPacketSize / 32) << 10)), ...);
    // Инициализация COUNTn_RX для двунаправленных точек и точек с двойной буферизацией
    ((*(reinterpret_cast<uint16_t*>(BdtBase + BdtCellOffset<BidirectionalAndBulkDoubleBufferedEndpoints> + 6)) = (BidirectionalAndBulkDoubleBufferedEndpoints::MaxPacketSize <= 62
      ? (BidirectionalAndBulkDoubleBufferedEndpoints::MaxPacketSize / 2) << 10
      : 0x8000 | (BidirectionalAndBulkDoubleBufferedEndpoints::MaxPacketSize / 32) << 10)), ...);

    // Сброс значение регистра COUNTn_RX для Tx точек с двойной буферизацией (возможно, это и не нужно, пока на всякий случай обнуляется)
    ((*(reinterpret_cast<uint16_t*>(BdtBase + BdtCellOffset<BulkDoubleBufferedTxEndpoints> + 2)) = 0), ...);
    ((*(reinterpret_cast<uint16_t*>(BdtBase + BdtCellOffset<BulkDoubleBufferedTxEndpoints> + 6)) = 0), ...);
  }
};

template<typename Endpoints>
using EndpointsManager = EndpointsManagerBase<SortedUniqueEndpoints<Endpoints>,
  typename Sample<IsBidirectionalOrBulkDoubleBufferedEndpoint, SortedUniqueEndpoints<Endpoints>>::type,
  typename Sample<IsOutEndpoint, SortedUniqueEndpoints<Endpoints>>::type,
  typename Sample<IsBulkDoubleBufferedTxEndpoint, SortedUniqueEndpoints<Endpoints>>::type>;

template<typename... Endpoints>
using EndpointsInitializer = EndpointsManagerBase<SortedUniqueEndpoints<TypeList<Endpoints...>>,
  TypeList<>,
  TypeList<>,
  TypeList<>>;

Не буду приводить код вспомогательных классов, дам краткое их описание:

  1. EndpointEPRn - класс, назначающий регистра EPnR для конечной точки. Реализован достаточно просто: всем конечным точкам регистры назнчаются по порядку. Если две конечные точки корректно делят один номер, то обеим назначается один регистр.

  2. BufferOffset - класс, вычисляющий смещение буфера пакетов для конечной точки. Класс тоже достаточно простой, смещение буфера конечной точки N есть сумма размеров конечных точек 0, ..., N-1.

  3. SortedUniqueEndpoints - класс, сортирующий все конечные точки по номерам и направлениям + исключение дубликатов. Текущее мое понимание USB допускает использование одной конечной точки в разных интерфейсах/конфигурациях, что приведет к дублированию в классе Device.

  4. IsBidirectionalOrBulkDoubleBufferedEndpoint, IsOutEndpoint, IsBulkDoubleBufferedTxEndpoint - предикаты для поиска конечных точек с определенными параметрами в общем списке.

Использовать менеджера конечных точек нужно таким образом:

using DefaultEp0 = ZeroEndpointBase<64>;
using LedsControlEpBase = OutEndpointBase<1, EndpointType::Interrupt, 64, 32>;
// Объявление менеджера конечных точек для получения их расширенной версии
using EpInitializer = EndpointsInitializer<DefaultEp0, LedsControlEpBase>;

// EpInitializer вернет расширенные версии точек.
// К сожалению, не придумал ничего, что бы позволило избежать необходимости таким образом немного дублировать код
using Ep0 = EpInitializer::ExtendEndpoint<DefaultEp0>;
using LedsControlEp = EpInitializer::ExtendEndpoint<LedsControlEpBase>;
// Объявление интерфейса, конфигурации и устройства.
using Hid = HidInterface<0, 0, 0, 0, HidDesc, LedsControlEp>;
using Config = HidConfiguration<0, 250, false, false, Report, Hid>;
using MyDevice = Device<0x0200, DeviceClass::InterfaceSpecified, 0, 0, 0x0483, 0x5711, 0, Ep0, Config>;

Внутри класса Device снова объявляется менеджер конечных точек, только в качестве аргументов передаются уже расширенных их версии:

template<
  ...
  typename _Ep0,
  typename... _Configurations>
  class DeviceBase : public _Ep0
{
  using This = DeviceBase<_Regs, _IRQNumber, _ClockCtrl, _UsbVersion, _Class, _SubClass, _Protocol, _VendorId, _ProductId, _DeviceReleaseNumber, _Ep0, _Configurations...>;
  using Endpoints = Append_t<typename _Configurations::Endpoints...>;
  using Configurations = TypeList<_Configurations...>;

  using EpBufferManager = EndpointsManager<Append_t<_Ep0, Endpoints>>;
  // Класс Device выступает в роли конечной точки
  using EpHandlers = EndpointHandlers<Append_t<This, Endpoints>>;
public:
  static void Enable()
  {
    _ClockCtrl::Enable();
    // Инициализация дескрипторов буферов пакетов для всех конечных точек
    EpBufferManager::Init();

В результаты применения шаблонов C++ достигнуты следующие результаты:

  1. Часть действий перенесена на этап компиляции, что позволило, пусть и не существенно, снизить размер прошивки (прошивка HID-устройства, позволяющего управлять одним светодиодом, занимает 2400 байтов).

  2. Снижен уровень необходимого дублирования кода, хотя полностью избежать его не удалось.

  3. Как следствие предыдущего пункта, снижен объем ошибок, которые программист может допустить. Также "бесплатно" получен некоторый контроль над возможным выходом за пределы буфера USB.

  4. *лично мой профит. Углублены знания шаблонов C++, разобрался в концептах, хотя в итоге нигде не применил.

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

Данный пост был посвящен не части библиотеки, касающейся USB в целом, а небольшом, но важном модулей распределения ресурсов между конечными точками. Буду рад вопросам и комментариям.

Посмотреть код целиком (USB пока тестирую только на F072RBT6, потому что есть disco с распаянным miniusb) можно тут. Надеюсь к лету победить USB хотя бы для МК серии F0 и F1. Смотрел на F4 - там все более круто (есть поддержка OTG) и сложно.