Stm32 + USB на шаблонах C++

    *За прикольную идею с изолентой спасибо @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) и сложно.

    Средняя зарплата в IT

    120 000 ₽/мес.
    Средняя зарплата по всем IT-специализациям на основании 9 250 анкет, за 1-ое пол. 2021 года Узнать свою зарплату
    Реклама
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее

    Комментарии 23

      +4
      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>;
      


      Не хотел бы я увидеть такой код в продакшине.
        0
        А почему? Просто мне кажется, Вы выбрали не самый ужасный кусок кода:)
        Про продакшн согласен наполовину: если все идет нормально, то нужды видеть это вроде особой и нет. Единственное, что нужно сделать сразу: заставить автора подробно закомментировать, что именно он имел ввиду в том или ином месте, чтобы спустя время другой программист мог что-то подправить.
        UPD: в реальном проекте, если бы мне позволили такое в принципе писать, я бы раздробил длинные объявления, сделал бы так примерно это место:
        using BidirectionalAndBulkDoubleBufferedEndpoints = typename Sample<IsBidirectionalOrBulkDoubleBufferedEndpoint, SortedUniqueEndpoints<Endpoints>>::type;
        ...
        using EndpointsManager = EndpointsManagerBase<Endpoints, BidirectionalAndBulkDoubleBufferedEndpoints , ...>;
        

        Из названий переменных должно быть понятно, что к чему.
        +2
        Жуть, это же невозможно читать…
          0
          Сложно поспорить с тем, что читать такой код нереально. При разработке дроблю на много мелких объявлений:
          using X = Y;
          Когда отладил и понял, что все правильно, соединяю. В основном писать шаблоны несложно (это обычно связка базовый класс + рекурсивная специализация). Напрягает только раскрытие variadic-ов, но ничего не поделать.
          Касательно USB, огромные массивы дескрипторов с магическими числами читать пусть и несложно, но разобраться и тем более отредактировать ничуть не легче.
            +3
            а как вы поддерживать этот код собираетесь? допустим через несколько месяцев, вы уже эту магию благополучно забудете, и тут внезапно придется в этом коде поправить баг, или запилить фичу?
            А если это будете не вы, а коллега?
              –1
              А ведь кто-то поддерживает STL…
                0
                В Microsoft STL поддерживает мистер STL (Stephan T. Lavavej).
          +1

          Добавлю к нелюбви USB. В реальном изделии необходимо заиметь свои vid/pid. Из-за этого при небольшой серии проще поставить конвертер. И разработка также упрощается.

            0
            Спасибо, упустил это в статье, это весомый аргумент, обязательно добавлю. Хотя, как я понял, есть какие-то условно-бесплатные пары для домашних проектов (хотя для домашних я бы по традиции использовал dead/beef)
              0
              А что, если рандомные взять, а не платить деньги, могут привлечь как-то?
                0
                конфликт может быть в ОС с драйверами, там же по PID\VID идентификация идет. Что мешает зайти на сайт usb.org, найти список вендоров и взять VID производителя МК, используемого вами, оттуда. А PID уже придумать свой, но взять из диапазона 0xA000 — 0xFF00. Я сомневаюсь, что он занят.

                для привлечения я думаю, достаточно взять чьи-нибудь PID\VID, собрать партию в миллион миллионов штук. В итоге, Вас смогут заметить и предъявить иск в нарушении прав, потери прибыли и чем-нибудь ещё.
                0

                На гитхабе где-то была репа, где раздают vid/pid бесплатно и совершенно официально

                  0
                  Да, и на хабре есть пост про этот репозиторий.
                  0
                  Конкретно могу сказать про STM. Если какой то коммерческий проект, то можно сделать запрос к ним и они поделятся PID/VID. Я запрашивал, мне прислали ответ:

                  We STMicroelectronics, hereby agree that:

                  "**** Ltd." company can use the original PID: 0x**** and VID: 0x**** pending to be licensed by USB-IF of "**** Ltd. ***" and solely for the purpose of “**** Ltd. *** using STM32F303VC silicon USB device microcontrollers” This license is revocable and nontransferable and no other rights are granted except those expressly stated here.

                  0
                  STM для CDC устройств вполне бесплатно раздает VID и PID. Полагаю, ничто не мешает их прикрутить к любому другому устройству на основе stm32, к тому же HID, например.
                    0
                    Может возникнуть конфликт драйверов.
                    0
                    Несколько месяцев назад успешно завершил написание своего USB CDC велосипеда на регистрах, тоже использовал C++, constexpr и шаблоны, правда у меня не настолько все зашаблонизировано.
                    Для размещения буферов мне показалось проще определить в линкере новую секцию для PMA, дальше достаточно определить обычную переменную с атрибутом __attribute__((section(".usb_pma"), used)) и она автоматом попадет куда нужно, без дополнительных телодвижений.
                    Правда окончательный адрес и размер буфера для записи в регистры все равно приходится вычислять на этапе выполнения т.к. память PMA мапится на шине контроллера кусками по 2 байта которые выровнены на границу 4 байта (проще говоря с пропусками, ну во всяком случае для STM32F303VCT6), а в constexpr невозможно делить адреса переменных.
                      0
                      Не очень понял про память с пробелами (то есть там реально 2 байта памяти, а потом 2 байта «ничего»? А что если разыменовать, например, как 4-байтовый int, все упадёт?).
                      В f0 по 2 байта адресация, что тоже несколько доставило сложностей: при копировании в буфер пришлось приводить к int16 и так копировать, но еще большее неудобство при отладке — окно memory показывает только половину, пришлось создать доп.буфер, копировать каждый раз содержимое PMA в этот буфер, и уже его смотреть.
                        0
                        Да, все верно, я не стал экспериментировать с чтением по 4 байта т.к. других проблем хватало, поэтому что будет в этом случае не знаю, да и в даташите про USB четко написано «Dedicated packet buffer memory SRAM access scheme: 1 x 16bit / word».
                        Поэтому у меня так же чтение/запись по 2 байта, только инкремент указателя на 2 приходится делать. И при чтении я тоже копирую из PMA во внешний буфер, хотя это делалось больше из соображений передачи данных которые могут превышать размер буфера в PMA.
                        Мне очень помогла эта статья, правда она на немецком и код у них довольно сильно запутан из-за того что все в кучу намешано, но некоторые моменты с логикой работы USB действительно удалось подсмотреть там.
                      0
                      Каждое использование тернарного оператора было бы неплохо выделить в статический метод, с хорошим названием. Должно облегчить понимание в разы. Init просто «порадовал». Вот, по моему, это и есть самое «темное» место… Поддерживать уйму комментов в коде это очень плохая затея, в проде видел, решалось переписыванием кода.
                        0
                        Спасибо за совет, согласен, так будет лучше, вынесу отдельно.
                          0
                          Каюсь, нагородил франкенштейна, хотя делов было на 2 минуты сделать лучше. Теперь инициализация выглядит так:
                          static void Init()
                          {
                              memset(reinterpret_cast<void*>(BdtBase), 0x00, BdtSize);
                              (InitTxAddressFieldInDescriptor<AllEndpoints>(), ...);
                              (InitRxAddressFieldInDescriptor<BidirectionalAndBulkDoubleBufferedEndpoints>(), ...);
                              (InitRxCountFieldInDescriptor<RxEndpoints>(), ...);
                              (InitSecondRxCountFieldInDescriptor<BidirectionalAndBulkDoubleBufferedEndpoints>(), ...);
                          }


                          Каждый из методов простой, например:
                          template<typename Endpoint>
                          static void InitRxCountFieldInDescriptor()
                          {
                              *reinterpret_cast<uint16_t*>(BdtBase + BdtCellOffset<Endpoint> + 2) = CalculateRxCountValue<Endpoint>();
                          }
                          
                        0
                        Добавьте в сложности:
                        Кривые драйвера CDC под Windows 10, теряют данные. Если поставить стандартные от STM то все пучком.
                        Багованные библиотеки микроконтроллера, где не учтены все тонкости USB работы что иногда приводит к дедлоку эндпоинта.

                        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                        Самое читаемое