Написать эту статью меня сподвигли те сложности, которые пришлось пройти в попытке разобраться, как же именно ядро контроллера STM32F103 работает с драйвером USB, который находится на борту. Имеющиеся туториалы (например, вот или вот) по созданию устройств, в том числе и композитных, в основном сфокусированы на особенностях использования библиотек. Я же хочу рассказать, как оно работает на уровне регистров. Некоторое время назад мне потребовалось создать драйвер для адресных светодиодов. Это довольно большой проект, в котором есть целый ряд интересных аспектов, но в рамках этой статьи достаточно упомянуть, что, поскольку этот драйвер предназначается для людей творческих профессий и увлечений - бутафоров, декораторов или косплееров, которые далеко не всегда умеют в программирование, а тем более микроконтроллеров, да и сам я больше художник, внутрь устройства был помещён целый интерпретатор команд, подобных G-кодам, который читает с флэш-карты различные сценарии, исполняет их и даже сигнализирует об ошибках. Само собой, на различную периферию оставалось не так много места. И в это место нужно было как-то втиснуть в том числе и обработку команд, поступающих через USB, поскольку это удобно для пользователя. И вот на этом месте оказалось, что места, как в ПЗУ, так и в ОЗУ для стандартных библиотек уже не остаётся: библиотеки часто пишутся так, чтобы отлавливать ошибки. Поэтому там, где достаточно присвоить пару заранее рассчитанных значений строго определённым регистрам возникают структуры из нескольких переменных и функции со множеством условных переходов. И те и другие пожирают довольно много памяти. Поверх этого библиотеки от STM сами по себе достаточно сложны по своей структуре. Переменные попрятаны в макросы, которые в свою очередь завёрнуты в функции, вызываемые через указатели. Так что пришлось во всё это влезать и разбираться. Процесс работы с инициализированным USB довольно прост. Но вот процесс инициализации   - это головоломка не для слабых умов. Чтобы понять, что там происходит, мне пришлось нарисовать довольно объёмную блок-схему, которая больше похожа на карту подземелья в D&D, чем на алгоритм. И прежде чем спуститься в это подземелье, нам понадобится следующее снаряжение. Итак, что нам понадобится для того, чтобы разобраться. Вообще, полезно иметь под рукой Reference Manual (DOC Id 13902, глава 22, страницы с 580 по 609). В этом документе содержатся адреса нужных для работы регистров и описание битов в этих регистрах. А также режимов работы этих битов (это важно). Для удобства программирования, я вынес эти адреса в макросы:   В этой части мы немного упрощаем себе жизнь, вызывая нужные нам регистры как переменные или работая с ними как с массивом:  CNTR - контролирует, какие из событий должны вызвать прерывание и обратить на себя внимание контроллера. Нам понадобятся 0x8000 (данные приняты) и 0x0400 (запрос на перезагрузку драйвера). Также есть события по переполнению буфера приёма (0x4000), отправке устройства в спящий режим (0x0800) и выхода из него (0x1000) и т.д. ISTR - собственно, описание прерывания. Старшие биты (с 15 по 8) - соответствуют регистру CNTR. Младшие 4 указывают номер конечной точки, которая требует внимания и бит номер 4 (0x0010) - это направление: 1 означает, что данные принимаются от хоста (обычно компьютера, но сейчас это умеют и смартфоны) в контроллер. 0 - что хост  ждёт данных от нас. DADDR - младшие 7 бит хранят номер, который хост присвоил нашему устройству в текущей сессии. Изначально ноль. Старший бит указывает, что драйвер USB принципиально готов к работе. BTABLE - смещение адреса описания конечных точек. Дело в том, что для работы нам нужно куда-то помещать данные - как принятые, так и те, которые мы будем отправлять. Местом для этого служит внутренняя память драйвера, Packet Memory (PMA). И для разметки этой памяти служит таблица, размещаемая в той же самой памяти начиная с позиции BTABLE. Эта таблица состоит из следующих полей: Адрес буфера отправки, количество байт, отправляемых на данном шаге, адрес буфера приёма, размер буфера приёма (и его заполненность). Наконец, PMA_BUF и PMA_SBUF - та самая внутренняя память драйвера, всего 512 байт. PMA_BUF обращается к ней как к массиву DWORD, а PMA_SBUF обращается к ней как к массиву WORD. Адресация в этой области памяти идёт по словам, так что обратиться к отдельному байту всё равно не получится. Конечные точки. Как видите, я обращаюсь к ним как по отдельности, так и как к элементам массива. В принципе достаточно второго, но поскольку в ряде случаев работа происходит с конкретной конечной точкой, то для наибольшей прозрачности проще сразу её обозначить. Сами конечные точки - это дополнительный уровень разделения потоков данных, интерфейсы, через которые хост и контроллер будут общаться. Нулевая конечная точка EP0R получает и передаёт данные, необходимые для настройки. Остальные выполняют ту работу, которую мы собственно хотим получить от нашего устройства.  Прежде чем начать работу, нам необходимо включить драйвер и подключить соответствующие ноги:    После инициализации и включения тактирования остаётся только выполнить разметку PMA и разрешить USB  принимать данные. За это отвечает функция USB_RESET():  Здесь нам потребуются пояснения, связанные с особенностями работы регистров конечных точек. Разные биты в них отвечают не только за разные режимы работы, но и по-разному реагируют на попытки записать в них 0 или 1. Так что присаживайтесь поудобнее. Старший бит, 0x8000, CTR_RX отвечает за приём и поднимается тогда, когда в область PMA для конечной точки пришли данные от хоста. Поднять его со стороны контроллера нельзя, только сбросить в 0. Бит 0x4000, DTOG_RX обозначает чётность принятого пакета данных. При записи в него 1 меняет своё значение. В случае работы с COM-портом он нам не понадобится. Биты 0x2000 и 0x1000 - STAT_RX - состояние приёмника конечной точки. Может принимать значения 0x3000 - готова к приёму, 0x2000 - данные обрабатываются и точка занята, 0x1000 - ошибка и 0x0000 - выключено. Эти биты переключаются так же, как и DTOG_RX. Именно поэтому  вместо EP0R = 0x3020 приходится городить EP0R = ((EP0R ^ 0x3020) & 0x3030) Бит 0x0800, SETUP - указывает на тип принятых данных, т.е. являются ли эти данные специфической командой. Бит 0x0400 и 0x0200 - EP_TYPE - тип конечной точки: 0x0000 - данные (BULK), 0x0100 - контрольная (CONTROL), 0x0400 - асинхронная (ISO) и 0x0600 - прерывание для хоста (INTERRUPT). Бит 0x0100, EP_KIND - дополнительный параметр. Для конечных точек типа BULK этот бит включает двойную буферизацию, режим, при котором области PMA для приёма и передачи данных меняются ролями после каждого принятого пакета. Это нужно для ускорения приёма-передачи. Для конечной точки типа CONTROL данный бит включает режим STATUS_OUT. Бывает нужен при завершении ответа, но в нашем случае работает и без него. Биты с 0x0080 по 0x0010 (CTR_TX, DTOG_TX, STAT_TX) работают так же, как и старшие 4, только отвечают не за приём, а за передачу данных хосту младшие 4 бита хранят номер конечной точки. Нам потребуется их заполнить когда устройство получит свой номер от хоста. Как видно, в проекте у нас 4 конечных точки: контрольная нулевая, одна на передачу (первая), одна для прерываний (вторая) и одна на приём (третья). Таблицу разметки мы расположили в начале PMA. В нулевой ячейке таблицы записываем позицию начальной ячейки отправки для контрольной точки. Затем - сколько байт нужно отправить. Затем - начальная ячейка приёма данных. Последняя ячейка содержит информацию о количестве места и количестве принятых данных: старший бит (0x8000) указывает, что блоки имеют размер 32 байта (если 0 - то 2 байта на блок), затем биты с 0x4000 по 0x0400 - количество этих блоков (минус 1). Максимальный общий размер - 512 байт. Оставшиеся 10 бит будут хранить число принятых конечной точкой байт. И далее по кругу для остальных конечных точек. При разметке важно, чтобы области PMA для разных нужд не пересекались. Иначе мы не сможем понять, что читаем. Следующий важный момент, без которого мы не сможем настроить наше устройство - дескрипторы. Это заранее заготовленные и отформатированные ответы на запросы хоста "что ты такое" и "как ты работаешь".    Дескрипторы обычно довольно подробно описываются во многих туториалах, да и в библиотеках и примерах их найти несложно. Так что эту часть объяснения оставлю коллегам. В частности, USB in a nutshell или USB made simple. Всё-таки моя статья скорее о том, как это добро правильнее передать чтобы оно было воспринято. При подключении устройство никак не определялось до конца. Вроде COM, но какой-то скомканный. Оказалось, компилятор не смог один из дескрипторов расположить в памяти единым куском. А читаю то я его "от забора и до обеда"! Так что компьютер, вместо того чтобы получить на свой запрос "как ты работаешь", вместо заранее заготовленного ответа "я делаю вот это и вот это" получал ответ "через Ж..." и прекращал дальнейшее общение с устройством. Пришлось этот дескриптор разбить на несколько частей по 16 байт и слать их одну за другой. Заработало. Потом оказалось, что этот дескриптор я  переобъявлял в другом файле, и от костыля удалось избавиться. Теперь каждый раз перепроверяю свои дефайны. Осталось определить несколько глобальных переменных и функций чтения-записи PMA   Во-первых, прерывание. Тут всё просто: определяем причину и выбираем обработчик. Причин у нас две - данные приняты и перезагрузить драйвер (см регистр CNTR выше)  Мы уже знакомы с USB_RESET(), так что не будем туда заглядывать. Настоящее приключение ждёт нас в CTR_LP_CTRX().  А теперь я расскажу, что тут происходит. И это именно то, ради чего вся эта статья писалась и ради чего вы её сейчас читаете. Вначале мы попадаем в цикл, задача которого - обработать все отдельные элементы прерывания по ISTR_CTR (он же 0x8000 - приём данных). Первое, что мы тут делаем, это определяем, какую именно конечную точку обрабатываем и обрабатываем приём или передачу. Номер конечной точки узнаём из прерывания. Это младшие 4 бита регистра ISTR. Приём или передача регулируются 5 битом (0x0010).  Если у нас актуальная конечная точка номер 1, и бит EP_CTR_TX (0x0080) поднят, хост хочет что-то от нас получить. Однако нам это не важно, так что мы просто опускаем бит CTR_TX (EP1R &= 0x8F0F), и, возможно, что-то делаем. Тут важно отметить, что если мы действительно хотим сформировать и отправить ответ, то нам нужно сначала перевести конечную точку в режим NAK, то есть осуществить операцию EP1R = (EP1R ^ 0x0020) & 0x8F3F, затем записать данные в PMA по адресу EP1TX_OFFSET (хранится в PMA_BUF(4)), затем записать в PMA_BUF(5) количество этих байт, и, наконец, перевести конечную точку в режим "готово" командой EP1R = (EP1R ^ 0x0030) & 0x8FBF Однако мы вызываем этот процесс из других частей программы, то есть когда именно у нас есть, что отправить. Для этого служит следующая процедура (данные для отправки записаны ранее в рабочий массив USB_COM_TX_Buff1, а их количество хранится в переменной USB_Bytes_to_send_left) Поместить данные (пакет не больше чем USB_EP1_MAX_PACKET_SIZE_TX) в рабочий буфер USB_COM_TX_Buff1, записать в переменную USB_Bytes_to_send_left количество отправляемых байт в текущем пакете Вызвать процедуру  Теперь перейдём к обработчику конечной точки номер 3. Она служит для приёма данных. Итак, актуальная конечная точка номер 3 и её бит EP_CTR_RX (0x8000) поднят. Мы опускаем этот бит и одновременно переводим конечную точку в режим ожидания: EP3R = (EP3R ^ 0x2000) & 0x3F8F. Также можно помахать флагами для внешних частей общей программы, но главное, что нас сейчас интересует - это количество принятых байт. Эта информация находится в младших 10 битах ячейки (PMA_BUF(15)&0x3FF). Далее мы перекладываем данные из PMA в рабочий массив и их как-то обрабатываем. Но это не всё. Когда мы закончим обработку этих данных, нам необходимо перевести конечную точку в состояние "готова к приёму". Это нужно сделать обязательно, иначе, по прошествии некоторого времени хост решит, что наше устройство зависло. Делается это очень просто: EP3R = (EP3R^0x3000)&0xBF8F. Также можно сбросить количество принятых байт в ноль, как это делалось в процедуре USB_RESET: PMA_BUF(15) = 0x8400. Данные лежат в массиве USB_Buff1 их количество записано в переменной USB_Recieved_bytes после обработки выполнить этот код:   Контрольная конечная точка, номер 0. Помимо простых запросов IN и OUT (EP_CTR_RX и EP_CTR_TX соответственно), есть ещё запрос SETUP (бит 0x0800 в регистре конечной точки EP0R). Принципиально у нас два возможных сценария развития событий - когда команда предполагает ответ и когда не предполагает. Разберём их на примере команды запроса дескриптора конфигурации (как наиболее сложный случай), и на примере назначения адреса устройства. Танец с бубном номер 1. Отправка дескриптора. Шаг 1. Из прерывания мы вошли в процедуру и в цикл внутри этой процедуры. Выяснили, что у нас 0 конечная точка. На всякий случай сохраняем состояние приёма и передачи (SaveTState = EP0R & 0x3030), а затем переводим нулевую конечную точку в состояние NAK (обработка данных) и по каналу приёма, и по каналу передачи. Так у нас будет время сформировать ответ хосту. На этом этапе направление передачи (ISTR_DIR) должно быть OUT (бит поднят), Бит EP_SETUP поднят. Бит EP_CTR_RX тоже поднят(его опускаем). Копируем данные из области PMA, размеченной для приёма в рабочие переменные. Это будет команда USB_Command = PMA_SBUF(EP0RX_OFFSET);параметр USB_wValue  = PMA_SBUF(EP0RX_OFFSET+2);индекс USB_wIndex  = PMA_SBUF(EP0RX_OFFSET+4);и длина требуемого ответа USB_wLength = PMA_SBUF(EP0RX_OFFSET+6); Структура команды следующая: верхние 8 бит (0x8000 ... 0x0100) - собственно требуемая операция, 7 бит (0x0080) - направление, если поднят, то хост ждёт от нас ответа 5 бит (0x0020) - указывает на то, что запрос специфичен для данного класса устройств младшие 4 бита - указывают на то, к какому слою USB запрос относится - к устройству, интерфейсу или конечной точке Теперь мы определяем, нужно ли отправить ответ. Для этого смотрим на USB_wLength. В нашем примере (отправить дескриптор конфигурации) это не ноль. Смотрим на команду. Её значение 0x0680 - отправить дескриптор. Смотрим на параметр. Он равен 0x0200 - дескриптор конфигурации. Записываем начало нашего дескриптора в область PMA, которая размечена для отправки данных хосту от конечной точки 0.  Тут у нас может быть два варианта. Поскольку изначально хост не знает размера этого дескриптора, он запрашивает только первые 9 байт (в которых содержится в том числе и информация об общей длине). Это 6 и 7 байты запроса, мы их храним в переменной USB_wLength. В этом случае мы записываем в ячейку PMA (PMA_BUF(1)), в которой хранится количество байт для отправки, число 9. После чего переходим к шагу 3 (LAST_IN_DATA). Если же у нас запрос полного дескриптора, то может быть 2 варианта: он меньше чем размер буфера отправки, или больше либо равен ему. Если меньше, то записываем то количество байт, которое есть и также переходим к шагу 3 (LAST_IN_DATA). Если же больше, то записываем в PMA_BUF(1) максимальное количество байт, которые можем отправить одним пакетом, и переходим к шагу 2 (IN_DATA). Шаг 2. IN_DATA. Открываем нулевую конечную точку для приёма и передачи EP0R = (EP0R^0x3030)&0xBFBF и переходим на следующий виток цикла. Теперь мы попадаем в цикл со следующими параметрами:(ISTR_DIR) должно быть IN (бит опущен),EP_SETUP опущен, Бит EP_CTR_TX поднят(его опускаем)Если что-то не так, то переводим нулевую конечную точку в состояние STALL (EP0R = (EP0R^0x1010)&0xBFBF) и выходим из цикла. Если всё так как надо, переводим нулевую конечную точку в состояние NAK на приём и передачу, доотправляем оставшиеся байты дескриптора. Тут может оказаться, что их ноль, но это нормально. Записываем это число в PMA_BUF(1). Опять же, в этом шаге мы можем крутиться до тех пор, пока оставшееся количество байт для дескриптора будет строго меньше чем максимальный размер пакета для отправки. Тогда мы можем перейти не снова в начало шага 2(IN_DATA), а к шагу 3 (LAST_IN_DATA). Шаг 3. LAST_IN_DATA. Открываем нулевую конечную точку для приёма и передачи EP0R = (EP0R^0x3030)&0xBFBF и переходим на следующий виток цикла. Мы снова попадаем в цикл со следующими параметрами:ISTR_DIR должно быть IN (бит опущен),EP_SETUP опущен, Бит EP_CTR_TX поднят(его опускаем).Если что-то не так, то переводим нулевую конечную точку в состояние STALL (EP0R = (EP0R^0x1010)&0xBFBF) и выходим из цикла. Если всё так как надо, переводим нулевую конечную точку в состояние RX_VALID, TX_STALL (EP0R = (EP0R^0x3010)&0xBFBF). И снова попадаем в цикл, но параметры у нас вот такие:ISTR_DIR должно быть OUT (бит поднят),EP_SETUP опущен,Бит EP_CTR_RX тоже поднят(его опускаем). Переводим нулевую конечную точку в состояние STALL (EP0R = (EP0R^0x1010)&0xBFBF) и выходим из цикла. Дескриптор отправлен. Танец с бубном номер 2. Получение адреса устройства. Из прерывания мы вошли в процедуру и в цикл внутри этой процедуры. Выяснили, что у нас 0 конечная точка. На всякий случай сохраняем состояние приёма и передачи (SaveTState = EP0R & 0x3030), а затем переводим нулевую конечную точку в состояние NAK (обработка данных) и по каналу приёма, и по каналу передачи. Так у нас будет время сформировать ответ хосту. На этом этапе направление передачи (ISTR_DIR) должно быть OUT (бит поднят), Бит EP_SETUP поднят. Бит EP_CTR_RX тоже поднят(его опускаем). Копируем данные из области PMA, размеченной для приёма в рабочие переменные. Это будет команда USB_Command = PMA_SBUF(EP0RX_OFFSET);параметр USB_wValue  = PMA_SBUF(EP0RX_OFFSET+2);индекс USB_wIndex  = PMA_SBUF(EP0RX_OFFSET+4);и длина требуемого ответа USB_wLength = PMA_SBUF(EP0RX_OFFSET+6); Смотрим на USB_wLength. На этот раз она равно нулю. Смотрим на команду. Её значение 0x0500 - назначить адрес. Значение адреса записано в USB_wValue, переписываем её во временную переменную USB_DADDR, заодно поднимая ей старший бит. Можно этого не делать, просто так немного удобнее. Переходим к шагу 2 (WAIT_STATUS_IN). Шаг 2. WAIT_STATUS_IN. Открываем нулевую конечную точку для приёма и передачи EP0R = (EP0R^0x3030)&0xBFBF и переходим на следующий виток цикла. Теперь мы попадаем в цикл со следующими параметрами:(ISTR_DIR) должно быть IN (бит опущен),EP_SETUP опущен, Бит EP_CTR_TX поднят(его опускаем) Тут мы только проверяем, нужно ли нам назначить адрес (для этого мы и записывали в USB_DADDR значение). Если да, то переписываем его в регистр адреса DADDR, и записываем в младшие 4 бита каждой из используемых конечных точек их номер - это будет сигнализировать драйверу, что с ними можно работать.И наконец переводим нулевую конечную точку в состояние STALL (EP0R = (EP0R^0x1010)&0xBFBF) и выходим из цикла. Адрес назначен. И напоследок, Команда (USB_Command) Что от нас требуется 0x0900  Выбрать активную конфигурацию устройства (сконфигурировать). Номер конфигурации находится в USB_wValue и не должен превышать максимальное для нашего устройства 0x0500 Назначить адрес для данного устройства в текущей сессии. Адрес может быть от 1 до 127 (0 означает, что устройство пока без адреса). Именно по этому номеру хост будет обращаться к устройству 0x0100 Выключить возможность перевода устройства в спящий режим и обратно через порт (Clear Feature)  0x0300 Включить возможность перевода устройства в спящий режим и обратно (SET Feature)  0x0B01 Включить определённый интерфейс. Номер интерфейса содержится в младшем байте USB_wIndex, не должно быть больше чем количество интерфейсов у устройства и устройство к этому моменту должно быть сконфигурировано. 0x0102 Перевести конечную точку в состояние VALID (0x3000 или 0x0030), при условии, что она находится в состоянии STALL. Номер конечной точки находится в младших 7 битах USB_wIndex, направление - в 8м бите. Устройство должно быть сконфигурировано, конечная точка - активна. 0x0302 Перевести активную конечную точку в состояние STALL 0x0221 Настроить COM порт В моём коде в этом месте ничего не происходит, но я заметил, что к этому моменту устройство и хост уже достаточно хорошо познакомились, и этот (а так же один из двух следующих) запрос приходит в момент подключения терминала. Таким образом сюда можно поставить, например, приветствие. 0x2021 Настроить бодрейт и прочие параметры передачи 0x2221 Настроить состояние линии управления портом 0x0680 Отправить хосту дескриптор. Какой именно определяется параметром USB_wValue:0x0100 - дескриптор устройства0x0200 - дескриптор конфигурации0x0300 - поддерживаемые кодировки0x0301 - поставщик, строка в юникоде0x0302 - имя устройства. строка в юникоде0x0303 - серийный номер устройства, человекочитаемая строка в юникоде 0x0080 Отправить хосту статус устройства. Если питается от хоста, то поднять бит 0x01, если возможность перевода в спящий режим и обратно включена, поднять бит 0x02. Иначе вернуть 0 0x0081 Статус интерфейса. Возвращаем 0. 0x0082 Статус конечной точки. Номер конечной точки находится в младших 7 битах USB_wIndex, направление - в 8м бите.Возвращает 1, если состояние - STALL. 0x0880 Вернуть номер конфигурации. Это той, которую мы назначали командой 0x0900 0x2021 Обозначить бодрейт и прочие параметры передачи. И вернуть результат:PMA_BUF(EP0TX_OFFSET) = 9600;//baudrate bytes PMA_SBUF(EP0TX_OFFSET+4) = 0;//parity*256+format PMA_SBUF(EP0TX_OFFSET+6) = 8;//datatype 0x2121 Передать хосту бодрейт и прочие параметры передачи. Ответ формируется так же, как и в предыдущем случае.  Надеюсь, что кому-то эта статья будет полезна. По крайней мере, именно такого разбора мне самому не хватало в процессе изучения библиотек и логики работы USB.