Создаём своё первое USB-устройство

от автора


Сегодня мы поговорим о создании USB-устройств, подключаемых к компьютеру и распознаваемых им. Этот пост задумывался как ваш первый источник для знакомства с разработкой USB-устройств.

Сначала небольшое предупреждение: я не считаю себя экспертом в USB. Не рассматривайте пост как авторитетное руководство; скорее, это документация к моему небольшому проекту по созданию простейшего USB-устройства E2E. Также в нём приведены ссылки на хорошие материалы, в которых тема рассмотрена более подробно.

Предисловие

Я уверен, что вам не нужно объяснять, в чём заключается польза USB-устройств. Они используются повсеместно, и в наших компьютерах есть множество разъёмов USB. А если их не хватает, то увеличить количество разъёмов можно при помощи USB-концентраторов. В статье я буду называть USB-устройства просто устройствами, а машины, к которым они подключаются — хостами.

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

Что такое USB?

Давайте сначала поговорим о том, что же такое USB. Для этого я процитирую первый параграф статьи из Википедии:

Universal Serial Bus (USB) — отраслевой стандарт, обеспечивающий обмен данными и подачу питания между различными типами электроники. В стандарте описывается его архитектура, в частности, физический интерфейс, коммуникационные протоколы передачи данных и подачи питания на хосты и с них, на периферийные устройства (дисплеи, клавиатуры, накопители) и с них, а также на промежуточные концентраторы (увеличивающие количество разъёмов хоста) и с них.

В остальной части статьи мы пошагово разберём это описание. Однако для начала самым важным для нас будет то, что USB — это последовательная шина. Биты поступают в шину один за другим, в отличие ситуации с параллельными шинами. Я уже десятки лет не видел ни одной параллельной шины. В последний раз я сталкивался с параллельной шиной, когда подключал жёсткий диск к материнской плате. Сегодня они устарели, современные шины в основном последовательные. Повторюсь, это означает передачу битов один за другим, а не параллельно. Не буду вдаваться в подробности этого (потому что не эксперт), насколько понимаю, что в этом контексте очень сложно организовать параллельную передачу. Такое понимание не передаёт всех тонкостей, но позволяет нам приступить к освоению USB: это способ последовательного обмена битами между хостом и устройством.

Однако USB — это не только спецификация соединения двух устройств и передачи битов между ними. В нём, как говорилось выше, частично реализован и протокол коммуникации. Давайте начнём разбираться на примере USB 2.0.

Проводники USB

Вероятно, вы видели различные типы разъёмов и кабелей USB, но все их конструкции сводятся к одному. Внутри соединения находятся проводники, подающие питание от хоста устройству и выполняющие передачу битов туда и обратно. Давайте рассмотрим типичное соединение USB 2.0. Внутри вы увидите такие проводники:

  1. Проводник +5 В, по которому устройство получает питание, если оно питается от хоста.
  2. Проводники D- и D+: я объединил их здесь, потому что они совместно работают над передачей 1 бита (не 2!) как дифференциальная пара. Подробнее мы поговорим об этом ниже.
  3. Проводник GND: как можно догадаться, это заземление.

В некоторых соединениях может быть ещё несколько контактов, например, контакт ID, но поскольку я хочу изложить минимально возможную информацию о USB, пока мы это пропустим. Будем работать только с четырьмя описанными выше проводниками.

▍ Примечание о USB-C

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

Сосредоточимся здесь только на одном важном аспекте: если устройство подключается через USB-C, это всё равно ничего не говорит ни о скорости, ни о версии USB. Это может быть и устройство USB 2.0, и более современное устройство USB 3.0.

Больше мы не будем касаться в статье USB-C.

▍ Передача данных по дифференциальной паре

Если вы никогда не видели раньше дифференциальных пар, то необходимость наличия двух проводов для передачи одного бита может озадачить вас. В самом базовом курсе по компьютерным архитектурам учат, что для передачи одного бита достаточно одного провода. Напряжение проводника (или стоит сказать «потенциал»?) относительно заземления (точки 0 В) передаёт значение бита. Если вы работаете с чем-то наподобие Raspberry Pi, то знаете, что если задать на контакте GPIO логическую 1, то это даст вам 3,3 В, и 0 В в противном случае.

И это нормально для очень коротких расстояний, например, в пределах чипа или даже в пределах коротких дорожек на плате. Однако нам часто требуются более длинные проводные соединения. Например, ваши клавиатура и мышь находятся как минимум в нескольких десятках сантиметров от компьютера.

И здесь нам на помощь приходят дифференциальные пары. Вместо одного провода напряжения V относительно GND мы используем два проводника. Один из них по-прежнему провод напряжения V, а второй — полярно противоположный -V. Когда дифференциальная пара исходит из одного компонента в другой, второй компонент учитывает разность напряжений между этими двумя проводниками. V - (-V) = 2V. Кажется, в этом нет особой пользы — мы получили удвоенное напряжение, и в данной модели это действительно так; однако эта модель не учитывает картину в целом. Мы предполагаем, что наши проводники идеально держат напряжение.

Пометим напряжения на этих проводниках как V+ и V-. Если использовать более реалистичную модель, то V+ не равно просто V, оно равно V плюс какой-то шум напряжений, который мы обозначим как Vn. То есть V+ = V + Vn. Аналогично, V- — это V- = -V + Vn. Значит, вычисляя то, что считает другое устройство, мы получим V+ - V- = (V + Vn) - (-V + Vn) = 2V. Шум напряжений пропал! Теперь становится понятнее, в чём заключаются преимущества дифференциальных пар.

Разумеется, это очень упрощённая модель. Например, почему мы считаем фактор шума одинаковым для обоих проводников? Чтобы подробнее узнать о дифференциальных парах, рекомендую посмотреть видеоролики Зака Питерсона на YouTube-канале Altium, начав с этого:

Теперь вам должно быть понятнее, почему я объединил проводники D+ и D- в один пункт: эти два проводника передают за раз один бит. Также должно быть понятнее, почему они обозначены как D, и что такое плюс и минус.

USB на печатной плате PCB

Если вы не намереваетесь создавать собственное оборудование для USB-устройства и использовать вместо этого готовую плату разработки, то можете пропустить этот раздел, но я рекомендую всё-таки пробежать его глазами.

При добавлении разъёма USB к печатной плате из какой-то библиотеки вы увидите перечисленные выше контакты, которые можно соответствующим образом маршрутизировать. У вашей платы может быть микроконтроллер или более сложный SoC, и вам совершенно точно нужно будет соединить дифференциальную пару с соответствующими соседними контактами на чипе. При маршрутизации дифференциальных пар стоит помнить о паре базовых аспектов.

Во-первых, дорожка от контакта D+ на разъёме USB до плюсового контакта дифференциальной пары чипа должна быть той же длины, что и другая дорожка до дифференциальной пары.

Во-вторых, эти дорожки должны быть очень близки друг к другу. Графический пример можно посмотреть в этой статье: объясняемое мной иллюстрирует изображение в начале (тоже отличная статья Зака Питерсона).

Эти два принципа должны объяснить, почему в нашем вычислении (достаточно упрощённом) мы предполагаем, что шум напряжений одинаков. Два проводника проложены почти в идентичном контексте, поэтому шум оказывается обнулённым.

В-третьих (потенциально это очень важно), мы должны иметь определённый импеданс для сигналов. Вычисления здесь очень сложны и учитывают множество факторов. Рекомендую для ознакомления посмотреть следующее видео:

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

Ниже я добавлю ссылку на ещё одно отличное видео Зака Питерсона о маршрутизации USB, но если вы только начинаете осваиваться, то вам вполне хватит предыдущего видео:

Различные скорости US

В начале мы сказали, что будем рассматривать USB 2.0, но это всё равно не полностью говорит нам о том, какой скорости мы достигнем. Даже в пределах одной версии USB могут существовать разные уровни скорости. Например, USB 2.0 может работать с full speed, то есть 12 Мбит/с, а также с high speed, то есть 480 Мбит/с. При соединении устройство и хост должны разобраться, какую скорость они будут использовать.

▍ Краткое примечание о скоростях для печатных плат

Если вы не проектируете свою печатную плату, этот раздел тоже можно пропустить, но всё равно будет полезно просмотреть его.

Существует очень много требований по корректной маршрутизации USB на печатной плате, однако выбор оборудования, соединяемого с обеих сторон, хоста и чипа в устройстве может быть достаточно произвольным, особенно если вы работаете не с high speed (а full speed, то есть 12 Мбит/с, должно быть вполне достаточно для простого прототипа).

Один из самых важных аспектов для достижения высоких скоростей — это попадание в нужный импеданс. Этот аспект нужно тщательно продумать, но, насколько я понимаю, если вы выберете full speed, то вам многое будет прощаться (и вы всё равно сможете изготовить представленный ниже работающий образец!). При такой скорости ширина дорожек не так важна, особенно дорожки от разъёма USB до чипа очень короткие.

Протокол и слои оборудования

Мы уже в достаточной мере обсудили оборудование, поэтому стоит перейти к самому протоколу и тому, как ПО с обеих сторон использует USB.

Я просто порекомендую изучить следующее видео:

В видео говорится об USB с точки зрения работы с Linux, что замечательно, но мы пока ничего не говорили о Linux. Однако в видео говорится и об USB в целом, и для примерно 45 минут контента плотность информации очень велика. Для понимания написанного в статье рекомендую посмотреть видео до примера с Linux. В нём очень многое рассказывается о кадрах USB, о работе с различными конечными точками и так далее. Также там есть отличный фрагмент о конфигурациях и о том, как одно устройство может выполнять различные функции USB. Думаю, самый важный вывод из этого видео заключается в том, что вы начнёте думать об USB как о сети устройств, и попытаетесь разобраться в этом. Я не буду перечислять все эти подробности здесь и просто порекомендую посмотреть видео.

▍ Классы USB-устройств и способы их использования хостами

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

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

Создание последовательного порта

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

В подобном сценарии я вижу два основных подхода: использование микроконтроллера или совместимого с Linux SoC. Во втором случае ядро само может выполнять основную часть работы, предоставляя нам очень чистое Linux API, но для нашего сценария это будет лишней тратой ресурсов, поэтому мы выберем работу с микроконтроллером.

▍ Микроконтроллер STM32 и плата Nucleo

Мы выберем первый подход, то есть используем микроконтроллер с поддержкой USB; в качестве платы разработки мы возьмём NUCLEO-F103RB. В США её можно купить по цене чуть больше $10.

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

Меньшая часть — это программатор микроконтроллера. Именно здесь также находится USB-соединение с компьютером, и через него мы будем программировать плату. Но будьте внимательны, это не то USB-соединение, которое нам нужно, и поначалу это может сбить вас с толку.

На этой плате Nucleo есть два больших чипа. Один находится на части платы с программатором, а другой — это тот микроконтроллер, который мы будем программировать, расположенный на большей части. USB-соединение, устанавливаемое вашим компьютером с платой — это, на самом деле, соединение с микроконтроллером на части с программатором. Этот программатор общается по протоколу ST-LINK, который, насколько я понимаю, является просто протоколом, построенным поверх обмена данными USB. Ваш компьютер и микроконтроллер программатора будут обмениваться USB-сообщениями в формате протокола ST-LINK, после чего этот микроконтроллер будет программировать «основной микроконтроллер». Я не знаю, как это реализовано конкретно на данной плате, да это для нас и неважно. Это может происходить, например, по SPI, а может, они используют между собой собственное USB-соединение, кто знает.

Если бы вы создавали собственную печатную плату с микроконтроллером STM32, то смонтировали бы один чип на плату, запрограммировали его через USB, и в дальнейшем использовали тот же разъём USB для бизнес-логики между устройством и хостом USB. Ниже я приведу две ссылки на видео Phil’s Lab (потрясающие материалы) по созданию печатных плат на основе STM32, а также о их программировании через USB. Первое видео длится всего двадцать минут и определённо стоит потраченного на него времени.

▍ Настройка настоящего разъёма USB

Разобравшись с тем, что USB-соединение платы Nucleo на самом деле не связано с чипом, который мы будем программировать, мы должны понять, как заставить работать разъём USB «основного» чипа.

В этом проекте после программирования чипа мы отключим от компьютера часть с программатором ST-LINK и подадим питание на основной чип с «реального» разъёма USB. Ниже мы пошагово рассмотрим этот процесс. Для написания ПО мы воспользуемся STM32CubeIDE, а затем используем STM32CubeProgrammer, чтобы сбросить это ПО на плату, чтобы ничего не усложнять.

Первым делом нам нужно определить, какие контакты микроконтроллера способны работать в качестве разъёма USB. Для простоты мы создаём устройство USB 2.0, и вот, что нам понадобится:

  • Контакт для подачи питания 5 В с хоста.
  • Контакт GND.
  • Контакты D+ и D-, которые будут обрабатывать данные дифференциальной пары USB.

GND найти легко, то же самое должно быть и с 5 В. Единственное, что нужно учесть при подаче питания на плату через «настоящий» разъём USB — нам придётся изменить контакты на перемычке JP5, чтобы сконфигурировать для приёма «внешних 5 В». Подробности можно узнать из документации платы, но на самом деле, это всё, что нам понадобится. Таким образом, мы разобрались с двумя контактами.

В приложении STM32CubeIDE нужно настроить (при помощи UI) PA12 так, чтобы он работал в качестве USB_DP, а PA11 — для работы в качестве USB_DM (разумеется, это, соответственно + и -). Конечный результат должен выглядеть так:

Здесь стоит отметить, что чип на этой плате для работы USB-соединения ожидает наличия внешнего резистора на 1,5 кОм. Чтобы не усложнять, я просто купил набор резисторов на 1,5 кОм (да, не самое дешёвое решение). Этот резистор должен подтягивать контакт PA12 до 3,3 В, которые также используются на плате Nucleo. Для соединения всего этого я использовал макетную плату.

Теперь, когда мы нашли эти четыре контакта на плате (плюс 3,3 В) и переключили перемычку подачи питания, всё готово для физического подключения к хосту. В моём случае это MacBook Pro с Mac OS. Чтобы подключить MacBook к этим отдельным контактам, я воспользовался разводным кабелем с Amazon. Конкретно для этого кабеля по ссылке я отсоединил блоки зажимов. Там было пять контактов, но в нашем проекте можно проигнорировать контакт S. Затем я при помощи простых проволочных перемычек подключил эти контакты к плате Nucleo, а также к макетной плате, где выполняется подтягивание для контакта PA12.

Мне хотелось бы вкратце вернуться к предыдущим замечаниям о совпадении длин дифференциальных пар, контроле импеданса и так далее: как видите, мы здесь совершенно не напрягаемся, но всё равно сможем обеспечить соединение со скоростью 12 Мбит/с. Именно это я имел в виду, когда говорил, что эти устройства могут быть достаточно снисходительными, по крайней мере, при некоторых скоростях.

Мы всё подключили и готовы к работе!

▍ Пишем ПО

Настроив контакты USB в CubeIDE, вы получите уведомления о настройке таймера — выберите опцию, позволяющую заняться этим самой CubeIDE, она всё исправит. Нам осталось настроить ещё несколько аспектов программной части USB.

В разделе Pinout & Configuration вы увидите подменю Middleware and Software Packs. В нём есть опция USB_DEVICE, давайте её откроем.

Для нашего проекта очень важно настроить режим устройства следующим образом:

Communication Device Class (Virtual Port Com)

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

На этом этапе CubeIDE также сгенерирует в нашей программе код на C. В файле main.c мы увидим следующую строку:

MX_USB_DEVICE_Init();

Если написанного выше вам недостаточно, посмотрите следующее видео с пошаговым объяснением:

Мы сделаем практически то же самое, что и в видео, чтобы обеспечить включение светодиода (отключение мы пропустим). В процедуре CDC_Receive_FS будет находиться такой код:

/* USER CODE BEGIN 6 */ if (Buf[0] == '1') { HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, 1); }

Этот вызов слоя HAL включает светодиод на плате, который подключён к контакту 5 разъёма A.

▍ Запись прошивки и запуск

Теперь можно собрать файл ELF и при помощи CubeProgrammer сбросить код на плату. После этого можно отключить программатор и подключить плату так, как мы описывали выше, запитав её от «внешних 5 В».

После включения платы вы должны увидеть её в менеджере устройств вашей операционной системы. Он должен распознать устройство, если, конечно, у вас не установлена какая-то экзотическая ОС. В разделе USB менеджера устройств вы должны увидеть что-то типа «COM port» или «Serial port».

Если вы хотите сделать что-то ещё более крутое, то можно вернуться в CubeIDE, перейти в меню USB_DEVICE middleware, в котором мы ранее настроили класс CDC для ПО микроконтроллер, и взглянуть на самый нижний раздел. Там вы сможете изменить значения в разделе Device Descriptor и ввести собственное имя для устройства. Оно должно отобразиться в менеджере устройств операционной системы.

Чтобы началось веселье, вам нужно подключиться к своему последовательному порту и отправить байт «1», после чего включится светодиод. В Mac OS новое устройство можно найти в дереве файловой системы /dev. На моей машине оно указано как /dev/tty.usbmodem497A0F6739561. Если вы работаете в Linux, то у вас тоже должно быть нечто подобное. Оно может выглядеть примерно как /dev/ttyUSB0. Для общения с этим последовательным устройством я использую Minicom, но вы можете выбрать практически что угодно, работающее с последовательными портами устройства. Я выполнил примерно такую команду:

minicom --device /dev/tty.usbmodem497A0F6739561

Затем я просто набрал цифру «1» на клавиатуре, и зелёный светодиод на плате Nucleo загорелся.

Заключение

Итак, по сути, мы с нуля создали USB-устройство для последовательного порта, которое будет распознаваться (теоретически) любой популярной операционной системой.

Я говорил, что с точки зрения разработки ПО можно выбрать один из двух подходов: использовать микроконтроллер или создать более сложную систему, позволив чему-то вроде ядра Linux обрабатывать ПО на стороне устройства. Для нашего проекта проще был первый подход, но я не большой фанат того, как всё это устроено в STM32.

Я по большей мере разработчик ПО, поэтому моё мнение будет с этой точки зрения. Во-первых, мне не нравится, что нам нужно генерировать кучу бойлерплейта при помощи IDE после щелчков в UI-меню. Мне бы хотелось, чтобы у меня была более гибкая библиотека, параметризируемая в коде, чтобы можно было написать что-то типа InitUsbDevice(UsbClass.CDC), а не ползать по UI для генерации кода. Кроме того, для STM32 нужна куча бойлерплейта, при этом тесно связанного с пользовательским кодом. На мой взгляд, это сильно усложняет ревью кода. Кроме того, возникает вопрос: как именно нам обновлять весь этот бойлерплейт при выпуске новой версии? Кажется, разработчики CubeIDE не подумали об этом заранее, и такое нередко случается в мире встроенных систем. Я читал исследования, в которых говорится, что подавляющее большинство встроенных устройств за свою жизнь ни разу не видят обновлений ПО (при чтении этого у многих разработчиков ПО может возникнуть отвращение). Здесь есть и другие тонкие моменты: например, что если однажды мы захотим заменить микроконтроллер на какой-то другой? В текущей ситуации мы окажемся очень сильно привязанными к экосистеме STM32.

Я не просто так упомянул, что Linux может работать в качестве USB-устройства: мне кажется, это гораздо более чистый подход. Linux API гораздо надёжнее и стандартизированнее. Работа с Linux будет основана на взаимодействиях с псевдофайлами и системными вызовами. Пользовательское пространство очень чётко отделено от пространства ядра. Кроме того, я воспринимаю Linux как слой HAL. Если на микроконтроллере можно запустить Linux, то мы сможем удобно отображать все эти устройства, нам не придётся пользоваться неуклюжими библиотеками HAL и так далее.

Тем не менее, очевидно, что иногда нам требуются легковесные, дешёвые и простые в изготовлении USB-устройства. SoC Linux более тяжеловесные и во многих сценариях использования могут быть лишней тратой ресурсов. Я хочу этим донести, что мне было хотелось иметь более портируемые и универсальные фреймворки для создания USB-устройств на голом железе. Некоторые аспекты популярных решений отталкивают меня от них: например, то, что фреймворки заставляют использовать конкретную систему сборки и так далее. Возможно, в вашем проекте всё это окажется не так важно, решать вам.

Telegram-канал со скидками, розыгрышами призов и новостями IT 💻


ссылка на оригинал статьи https://habr.com/ru/articles/869682/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *