
Введение
Представьте: вам дали USB-устройство и попросили написать для него драйвер. Поначалу эта задача кажется пугающей, правда? Для создания драйверов нужно писать код ядра, а код ядра сложный и низкоуровневый, его трудно отлаживать и так далее.
Однако всё это неправда. На самом деле, написание драйвера для USB-устройства не намного сложнее, чем написание приложения, использующего Sockets.
Этот пост будет высокоуровневым введением в использование USB для разработчиков, мало работавших с оборудованием и просто желающих применить эту технологию. Существуют потрясающие ресурсы наподобие USB in a NutShell, подробно объясняющие работу USB (изучите их, если вам нужна дополнительная информация), но они довольно сложны для тех, кто раньше ни разу не работал с USB и не имеет опыта в сфере «железа». Чтобы пользоваться USB, не нужно быть разработчиком встраиваемых систем; точно так же, как не нужно быть специалистом по сетям для использования Sockets и Интернета.
USB-устройство
Мы будем работать с телефоном на Android в режиме Bootloader. Причины такого выбора:
-
Это устройство, которое можно легко достать
-
Используемый им протокол хорошо задокументирован и невероятно прост
-
Драйверы для него обычно не установлены в системе заранее, поэтому операционная система не будет влиять на наши эксперименты
Способ переключения телефона в режим Bootloader зависит от конкретного устройства; обычно для этого нужно удерживать сочетание кнопок при запуске телефона. В моём случае для этого нужно при включении питания телефона удерживать кнопку увеличения громкости.
Ручной опрос устройства
Опрос (enumeration) — это процесс, при котором хост запрашивает у устройства информацию о нём. Он происходит автоматически при подключении устройства; при этом операционная система обычно решает, какой драйвер загрузить для устройства. В случае большинства стандартных устройств ОС смотрит на USB Device Class и загружает драйвер, поддерживающий этот класс. В случае специфичных устройств пользователь обычно устанавливает созданный производителем драйвер, ищущий VID (Vendor ID) и PID (Product ID), а не определяющий, должен ли он работать с устройством.
Базовая информация
Даже без драйвера телефон при подключении к компьютеру всё равно распознаётся, как USB-устройство, потому что спецификация USB определяет стандартный способ идентификации устройства для хоста; подробнее мы поговорим об этом чуть ниже.
В Linux можно использовать удобный инструмент lsusb, чтобы увидеть, как идентифицирует себя устройство:
$ lsusb...Bus 008 Device 014: ID 18d1:4ee0 Google Inc. Nexus/Pixel Device (fastboot)...
Bus и Device — это просто идентификаторы физического USB-разъёма, к которому подключено устройство. В вашей системе они, скорее всего, будут другими, потому что значения зависят от того, к какому разъёму вы подключите устройство. Самое интересное здесь — это ID. Первая часть 18d1 — это ID производителя (Vendor ID, VID), а вторая часть 4ee0 — ID продукта (Product ID, PID). Это идентификатор, передаваемый устройством хосту для своей идентификации. VID присваивается организацией USB-IF компаниям, которые платят ей много денег (в данном случае Google), а PID присваивается компанией конкретному продукту (в данном случае Nexus/Pixel Bootloader).
Информация о классе и драйвере
При помощи команды lsusb -t можно узнать USB-класс устройства и драйвер, который сейчас работает с ним:
$ lsusb -t.../: Bus 008.Port 001: Dev 001, Class=root_hub, Driver=xhci_hcd/1p, 480M |__ Port 001: Dev 002, If 0, Class=Hub, Driver=hub/4p, 480M |__ Port 003: Dev 003, If 0, Class=Hub, Driver=hub/4p, 480M |__ Port 002: Dev 014, If 0, Class=Vendor Specific Class, Driver=[none], 480M...
Здесь мы видим полное дерево подключенных к системе USB-устройств. Самая нижняя строка в этой части дерева — наше устройство (Bus 008, Device 014, как мы видели из предыдущей команды). Часть Class=Vendor Specific Class определяет, что устройство не использует ни один из стандартных USB-классов (например, HID, Mass Storage или Audio), а применяет специальный протокол, определяемый производителем. Часть Driver=[none] просто сообщает нам, что ОС не загрузила драйвер для устройства; это хорошо, потому что мы собираемся написать собственный.
Примечание по WindowsЕсли вы работаете с Windows, то у вас нет lsusb, но вы всё равно можете найти бóльшую часть этой информации в Диспетчере устройств или в инструментах наподобие USB Device Tree Viewer
Также нам важны VID и PID, потому что это единственная идентифицирующая информация, которая у нас есть. Device Class не особо полезен, потому что в данном случае это просто Vendor Specific Class, который любой производитель может использовать для любого своего устройства. Однако вместо того, чтобы делать это всё в ядре, можно написать приложение пользовательского пространства, выполняющее те же задачи. Его намного проще писать и отлаживать (к тому же пользовательское пространство — самое подходящее место для драйверов, но это уже отдельная тема). Для этого мы можем воспользоваться библиотекой libusb, предоставляющей простой API для обмена информацией с USB-устройствами из пользовательского пространства. Эту цель мы реализуем при помощи стандартного драйвера, который можно загрузить для любого устройства и который позволяет приложениям пользовательского пространства запрашивать доступ к устройству и общаться с ним напрямую.
Опрос устройства при помощи libusb
То же самое, что мы делали вручную, можно сделать программно. Показанная ниже программа инициализирует libusb, регистрирует обработчик события горячего подключения для устройств, соответствующих комбинации VendorId / ProductId 18d1:4ee0, а затем ждёт, пока это устройство будет подключено к хосту.
#include <print>#include <libusb-1.0/libusb.h>auto hotplug_callback( libusb_context *ctx, libusb_device *device, libusb_hotplug_event event, void *user_data) -> int { std::println("Device plugged in!\n"); return 0;}auto main() -> int { // Создание контекста для взаимодействия с драйвером libusb libusb_context *context = nullptr; libusb_init(&context); // Регистрация обработчика событий горячего подключения для ожидания подключения устройства libusb_hotplug_callback_handle hotplug_callback_handle; libusb_hotplug_register_callback( context, LIBUSB_HOTPLUG_EVENT_DEVICE_ARRIVED, // Событие подключения устройства LIBUSB_HOTPLUG_ENUMERATE, // Запуск события для уже подключенных устройств 0x18d1, 0x4ee0, // Найденные ранее VID и PID LIBUSB_HOTPLUG_MATCH_ANY, // Сопоставление с USB-классами hotplug_callback, nullptr, // Обратный вызов &hotplug_callback_handle ); // Обработка событий libusb while (true) { if (libusb_handle_events(context) < 0) break; } // Очистка libusb_hotplug_deregister_callback(context, hotplug_callback_handle); libusb_exit(context);}
Если скомпилировать и запустить этот код, то при подключении устройства должен получиться следующий вывод:
$ ./libusb_enumerateDevice plugged in!
Поздравляю! Теперь у нас есть программа, распознающая устройство без необходимости использования кода ядра.
Примечание по Windows
В Linux всё это обычно «просто работает». Если по какой-то причине драйвер всё-таки загружается, то его можно принудительно отключить функцией
libusb_detach_kernel_driver().В Windows всё может выглядеть немного иначе. Если повезёт, у устройства есть
Microsoft OS Descriptor, сообщающий Windows, что для него нужно загрузить драйверWinusb.sys. В этом случаеlibusbможет напрямую общаться с ним. Однако если драйвер не загружен (устройство отображается в Диспетчере устройств со значком ⚠️), то может понадобиться Zadig для принудительной замены драйвера наWinusb.sysили на другой поддерживаемый драйвер. Дополнительную информацию можно найти в libusb Wiki
Общение с устройством
Следующий шаг — это получение ответа от устройства. Сейчас это проще всего сделать при помощи стандартизованной конечной точки управления Control. Эта конечная точка всегда находится на ID 0x00 и имеет стандартизованный протокол. Кроме того, эту конечную точку ранее использовала ОС для идентификации устройства и получения его VID:PID.
Здесь мы немного забегаем вперёд, ведь мы ещё даже не знаем, что такое конечные точки, но обещаю, что скоро всё станет ясно. Пока вы можете просто считать конечные точки портами устройства в сети с определённым номером, на которые можно передавать данные.
Запрашиваем наши первые данные
Использовать эту конечную точку мы можем при помощи ещё одной функции libusb, созданной специально для отправки запросов к этой конечной точке. Мы можем дополнить обработчик события горячего подключения следующим кодом:
// Открываем устройство, чтобы с ним можно было общатьсяlibusb_device_handle *handle = nullptr;libusb_open(device, &handle);std::vector<std::uint8_t> data(0xFF);// Выполняем передачу Controlconst auto result = libusb_control_transfer( handle, uint8_t(LIBUSB_ENDPOINT_IN) | // Запрашиваем данные из устройства... LIBUSB_RECIPIENT_DEVICE | // об устройстве в целом... LIBUSB_REQUEST_TYPE_STANDARD, // при помощи стандартного запроса. LIBUSB_REQUEST_GET_STATUS, // Отправляем запрос GET_STATUS 0x00, // значение wValue, равное 0x00 0x00, // значение wIndex, равное 0x00 data.data(), data.size(), // Буфер для считывания данных 1000 // Таймаут 1000 мс);// В случае отсутствия ошибок выводим данные, возвращённые устройствомif (result >= 0) print_bytes(std::span(data).subspan(0, result));// Снова закрываем устройствоlibusb_close(handle);
Теперь этот код будет отправлять устройству запрос GET_STATUS сразу после его подключения и выводить в консоль переданные им данные.
$ ./libusb_enumerateAddr 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F0000: 01 00
Эти байты поступили из самого устройства! Декодировав их при помощи спецификации, можно понять, что первый байт определяет, имеет ли устройство собственное питание (значение 1 здесь логично, ведь устройство работает от аккумулятора), а второй байт означает, что оно не поддерживает удалённое пробуждение (то есть оно не может выводить хост из сна).
Есть ещё несколько стандартизованных типов запросов (а некоторые устройства даже добавляют собственные простые запросы!), но нас (и операционную систему) в первую очередь интересует запрос GET_DESCRIPTOR.
Запрос дескриптора
Дескрипторы — это двоичные структуры, которые обычно жёстко прописаны в прошивке USB-устройства. Именно они сообщают хосту, чем является устройство, что оно умеет и какой драйвер нужно загрузить для него операционной системе. При подключении устройства хост просто отправлять множество запросов GET_DESCRIPTOR стандартизированной Control Endpoint с ID 0x00, чтобы получить структуру, содержащую всю информацию, которая ему нужна. И здорово то, что мы тоже можем это сделать!
Вместо запроса GET_STATUS мы теперь отправим запрос GET_DESCRIPTOR:
const auto result = libusb_control_transfer( handle, uint8_t(LIBUSB_ENDPOINT_IN) | // Запрашивает данные у устройства... LIBUSB_RECIPIENT_DEVICE | // об устройстве в целом... LIBUSB_REQUEST_TYPE_STANDARD, // при помощи стандартного запроса. LIBUSB_REQUEST_GET_DESCRIPTOR, // Отправляем запрос GET_DESCRIPTOR (LIBUSB_DT_DEVICE << 8) | 0, // Запрашиваем дескриптор нулевого устройства 0x00, // Language ID, здесь его на него можно не обращать внимания data.data(), data.size(), // Буфер для чтения данных 1000 // Таймаут 1000 мс);
Теперь код возвращает следующие данные:
$ ./libusb_enumerateAddr 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F0000: 12 01 00 02 00 00 00 40 D1 18 E0 4E 99 99 01 020010: 00 01
Для декодирования этих данных нужно заглянуть в спецификацию USB, Chapter 9.6.1 Device. Из этой главы мы узнаем, что формат выглядит так:
struct DeviceDescriptor { u8 bLength; u8 bDescriptorType; u16 bcdUSB; u8 bDeviceClass; u8 bDeviceSubClass; u8 bDeviceProtocol; u8 bMaxPacketSize0; u16 idVendor; u16 idProduct; u8 iManufacturer; u8 iProduct; u8 iSerialNumber; u8 bNumConfigurations;};
Закинув данные в ImHex и передав в её Pattern Language это определение структуры, мы получим следующий результат:

Вот и они! idVendor и idProduct соответствуют значениям, найденным ранее при помощи lsusb.
Но существует дескриптор не только устройства. Есть также дескрипторы конфигурации, интерфейса, конечной точки, строки, а также ещё пара дескрипторов. Все эти дескрипторы можно считать при помощи того же запроса GET_DESCRIPTOR к конечной точке Control. Мы можем делать это вручную, но, к счастью, у lsusb есть опция, делающая это за нас!
$ lsusb -d 18d1:4ee0 -vBus 001 Device 012: ID 18d1:4ee0 Google Inc. Nexus/Pixel Device (fastboot)Negotiated speed: High Speed (480Mbps)Device Descriptor: bLength 18 bDescriptorType 1 bcdUSB 2.00 bDeviceClass 0 [unknown] bDeviceSubClass 0 [unknown] bDeviceProtocol 0 bMaxPacketSize0 64 idVendor 0x18d1 Google Inc. idProduct 0x4ee0 Nexus/Pixel Device (fastboot) bcdDevice 99.99 iManufacturer 1 Synaptics iProduct 2 USB download gadget iSerial 0 bNumConfigurations 1 #... и ещё 51 строка
В этом выводе показаны другие дескрипторы, которые есть у устройства. В частности, у него есть один Configuration Descriptor, содержащий Interface Descriptor для интерфейса Android Fastboot. И этот интерфейс теперь содержит две конечные точки (Endpoint). Здесь устройство рассказывает хосту обо всех остальных конечных точках наряду с конечной точкой Control, и мы можем использовать их на следующем этапе, чтобы наконец-то отправить данные на интерфейс Fastboot устройства!
Конечные точки
Давайте для начала немного поговорим о конечных точках. Мы уже знаем о конечной точке Control по адресу 0x00. Конечные точки, по сути, эквивалентны портам, открытым устройством в сети для отправки и получения данных. В своём дескрипторе устройство сообщает, какие конечные точки у него есть, и затем обслуживает их в своей прошивке. То есть нам даже не нужно выполнять сканирование портов или знать, что SSH обычно работает на порте 22: мы можем просто узнать, какие интерфейсы есть у устройства, на каком языке они говорят и как можно с ними общаться. Но если посмотреть на показанные выше дескрипторы, можно увидеть, что Control Descriptor там нет. Вместо него есть два других с разными типами.
Тип передачи Control
Он всегда только один для устройства и всегда привязан к адресу конечной точки 0x00. Он используется для первоначального конфигурирования и запроса информации об устройстве.
Основное предназначение контрольной точки Control — решение проблемы курицы и яйца: невозможно общаться с устройством, не зная его конечных точек, с которыми нужно общаться. Поэтому она даже не указывается в дескрипторах. Она относится не к интерфейсам, а к самому устройству. И благодаря спецификации мы знаем о её существовании без необходимости вещания.
Она предназначена для задания простых значений конфигурации или запросов малых объёмов данных. Функция в libusb даже не позволяет задать адрес конечной точки для выполнения запроса управления, потому что существует только одна конечная точка управления, и она всегда находится по адресу 0x00
Тип передачи Bulk
Конечные точки Bulk используются, когда нужно передавать большие объёмы данных. Они применяются, когда есть большое количество некритичных по времени данных, которые просто нужно передать по проводу. Они применяются для таких вещей, как Mass Storage Class, CDC-ACM (Serial Port over USB) и RNDIS (Ethernet over USB).
Существует одна тонкость: данные, передаваемые через конечные точки Bulk, имеют широкую полосу пропускания, но низкий приоритет. Это значит, что данные Bulk всегда просто заполняют всю оставшуюся полосу пропускания. Все передачи Interrupt и Isochronous (подробнее о них ниже) имеют более высокий приоритет, поэтому если передавать по одному и тому же соединению данные Bulk и Isochronous, то полоса пропускания передачи Bulk будет сужена, пока Isochronous не передаст свои данные в запрошенный период.
Тип передачи Interrupt
Конечные точки Interrupt противоположны конечным точкам Bulk. Они позволяют отправлять малые объёмы данных с очень низкими задержками. Например, клавиатуры и мыши используют этот тип передачи в классе HID, чтобы опрашивать нажатия кнопок по тысяче с лишним раз в секунду. Если кнопка не нажата, передача сразу прекращается без отправки полного сообщения о сбое (лишь NAK), и только в случае реальных изменений мы получим описание произошедшего.
Важно здесь то, что хотя они называются конечными точками Interrupt, прерываний не происходит. Устройство по-прежнему не общается с хостом, пока его не спросят. Хост просто опрашивает его так часто, что это походит на прерывание. Функции libusb, обрабатывающие передачи Interrupt, ещё сильнее абстрагируют это поведение. Можно начать передачу Interrupt, и функция заблокируется, пока устройство не передаст полный ответ.
Тип передачи Isochronous
Конечные точки Isochronous достаточно уникальны. Они используются для объёмов данных большего размера, передать которые очень важно вовремя. В основном они применяются для потоковых интерфейсов наподобие Audio или Video, в которых любая задержка или латентность сразу становится заметна, проявляясь в виде заикания или рассинхронизации. В libusb они работают асинхронно. Можно одновременно настроить несколько передач, они будут поставлены в очередь, а после доставки данных передаётся событие, чтобы можно было обработать их и поставить в очередь дальнейшие запросы. Этот тип обычно нечасто применяется вне классов Audio и Video.
Входные/выходные конечные точки
Наряду с типом передачи у конечных точек есть и направление. Следует помнить, что USB — это интерфейс, полностью ориентированный на модель «ведущий — ведомый». Все запросы всегда выполняет только хост, а устройство никогда не отвечает, если его не попросил об этом хост. То есть устройство не может отправлять данные напрямую хосту; он должен попросить устройство передать данные.
Вот для этого и нужно направление.
-
Конечные точки
INиспользуются, когда хост хочет получить данные. Он делает запрос к конечной точкеINи ждёт, пока устройство ответит ему данными. -
Конечные точки
OUTиспользуются, когда хост хочет передать данные. Он совершает запрос к конечной точкеOUT, а затем сразу же передаёт данные, которые нужно отправить. В этом случае устройство только подтверждает (ACK) получение данных, но не отправляет никаких дополнительных данных в ответ.
Я запоминаю направления при помощи этой аналогии «ведущий — ведомый». Ведущий сосредоточен на себе и всегда обращается к окружающим с этой точки зрения.
IN: я хочу получить данные
OUT: я хочу отправить данные
В отличие от типа передачи, направление кодируется в адресе конечной точки. Если старший бит (MSB) имеет значение 1, то это конечная точка IN, если 0, то это конечная точка OUT. (Если вы разбираетесь в оборудовании, то могли вспомнить, что это та же концепция, что и в интерфейсе I2C.)
Из этого следует два вывода:
-
Одновременно может быть доступно не более 27−1=127 конечных точек
-
27, потому что под адреса доступно 7 бит
-
−1, потому что у нас всегда есть конечная точка управления, которая находится на постоянном адресе
0x00.
-
-
Конечные точки абсолютно однонаправленные. Мы используем конечную точку или для запроса данных, или для их передачи, но никогда для двух операций одновременно
-
Именно поэтому интерфейс Fastboot имеет две конечные точки Bulk: одна занимается прослушиванием запросов, отправляемых хостом, а другая отвечает на те же самые запросы
-
Наконец-то Fastboot
Теперь, когда у нас есть вся эта информация о USB, давайте взглянем на протокол Fastboot. Лучшая документация по нему находится в исходном коде u-boot и в его документации.
Согласно документации, протокол невероятно прост. Хост отправляет команду-строку, а устройство отвечает 4-символьным кодом статуса, за которым следуют данные.
Вот выдержка из документации
Host: "getvar:version" запрос переменной версииClient: "OKAY0.4" возврат версии "0.4"Host: "getvar:nonexistant" запрос какой-то неопределённой переменнойClient: "OKAY" возврат значения ""
Давайте дополним наш код:
// Открываем устройство, чтобы можно было общаться с нимlibusb_device_handle *handle = nullptr;libusb_open(device, &handle);// Запрашиваем интерфейс, чтобы libusb знала, на какой интерфейс// мы передаём данныеlibusb_claim_interface(handle, 0);// Подготавливаем 64-байтный буфер для запроса и ответа// В документации указано, что нужно использовать 64 байта для full-speed// и 512 байт для high-speed. Так как это устройство full-speed,// мы задаём размер 64 байта.std::vector<uint8_t> bytes(64);// Копируем команду "getvar:version"// в начало буфераstd::ranges::copy( "getvar:version", bytes.begin());// Выполняем передачу Bulk этих данных на конечную точку OUT 0x02int num_bytes_transferred = 0;libusb_bulk_transfer( handle, // Дескриптор устройства LIBUSB_ENDPOINT_OUT | 0x02, // Конечная точка OUT 0x02 bytes.data(), bytes.size(), // Отправляемые данные &num_bytes_transferred, // Количество отправляемых данных 1000 // Таймаут 1000 мс);// Вывод переданных данныхstd::println("Request: {}", std::string_view( reinterpret_cast<const char *>(bytes.data()), num_bytes_transferred ));// Очищаем буферstd::ranges::fill(bytes, 0x00);num_bytes_transferred = 0;// Выполняем передачу Bulk на конечную точку IN 0x01libusb_bulk_transfer( handle, // Дескриптор устройства LIBUSB_ENDPOINT_IN | 0x01, // Конечная точка IN 0x81 bytes.data(), bytes.size(), // Буфер для получения данных &num_bytes_transferred, // Количество получаемых байт 1000 // Таймаут 1000 мс);// Вывод возвращённых символовstd::println("Response: {}", std::string_view( reinterpret_cast<const char *>(bytes.data()), num_bytes_transferred ));// Снова освобождаем интерфейсlibusb_release_interface(handle, 0);// Закрываем дескриптор устройстваlibusb_close(handle);
Теперь при подключении устройства в терминал будет выводить следующее сообщение:
$ ./libusb_enumerateRequest: getvar:versionResponse: OKAY0.4
Похоже, оно соответствует документации! Первые четыре байта — OKAY, они указывают, что запрос выполнен успешно. Остальные данные после них — 0.4, что соответствует реализованной версии Fastboot по документации: v0.4
В заключение
Вот и всё! Мы успешно написали с нуля первый драйвер USB, не прикасаясь к ядру.
Те же самые принципы применимы ко всем драйверам USB. Протокол может быть существенно сложнее, чем Fastboot (я рвал на себе волосы, пытаясь разобраться в том кошмаре, который представляет из себя протокол MTP), но всё остальное будет таким же. Не намного сложнее, чем TCP через сокеты, правда?
ссылка на оригинал статьи https://habr.com/ru/articles/1023354/