Реверс-инжениринг драйверов USB-устройств на примере машинки на радиоуправлении
Один из аргументов любителей Windows перед любителями Linux — недостаток драйверов для оборудования под эту ОС. С течением времени ситуация выправляется. Сейчас она уже гораздо лучше, чем 10 лет назад. Но иногда можно встретить какое-то устройство, которое не распознаётся вашим любимым дистрибутивом. Обычно это будет какая-нибудь USB-периферия.
Красота свободного софта в том, что эту проблему можно решить самостоятельно (если вы программист). Конечно, всё зависит от сложности оборудования. С трёхмерной веб-камерой у вас может и не получится — зато многие USB-устройства довольно просты, и вам не придётся нырять в глубины ядра или закапываться в С. В этом уроке мы с вами при помощи Python по шагам изготовим драйвер к игрушечной радиоуправляемой машинке.
Процесс по сути будет реверс-инженирингом. Сначала мы подробно изучим устройство, затем сохраним данные, которыми оно обменивается с драйвером в Windows, и попытаемся понять, что они означают. Для нетривиальных протоколов вам может потребоваться как опыт, так и удача.
Знакомство с USBUSB — шина с управлением хостом. Хост (PC) решает, какое устройство отправляет данные по проводам, и когда именно. Даже в случае асинхронного события (нажатие кнопки на клаве) оно не отправляется хосту сейчас же. Поскольку на каждой шине может быть до 127 устройств (и ещё больше, если через хабы), такая схема работы облегчает управление.Также у USB есть многослойная система протоколов — примерно как у интернета. Самый нижний уровень обычно реализован в кремнии. Транспортный слой работает через туннели (pipe). Потоковые туннели передают разные данные, туннели сообщений — сообщения для управления устройствами. Каждое устройство поддерживает минимум один туннель сообщений. На высшем уровне приложения (или класса) есть протоколы вроде USB Mass Storage (флэшки) или Human Interface Devices (HID), устройства для взаимодействия человека с компьютером.
В проводах USB-устройство можно рассматривать как набор конечных точек, или буферов ввода/вывода. У каждого есть направление данных (ввод или вывод) и тип передачи. По типам буферы бывают следующие: прерывания, изохронные, управляющие и пакетные.Прерывания передают данные по чуть-чуть в реальном времени. Если пользователь нажал клавишу, устройство ждёт, пока хост не спросит «не нажимали ли там кнопочки?». Хост не должен тормозить, и эти события не должны потеряться. Изохронные работают примерно так же, но не настолько жёстко — они разрешают передавать больше данных, при этом допуская их потерю, когда это не критично (например, веб-камеры).
Пакетные предназначены для больших объёмов. Чтобы они не забивали канал, им отдаётся всё место, которое сейчас не занято другими данными. Управляющие используются для управления устройствами, и только у них есть жёстко заданный формат запросов и ответов. Набор буферов со связанными с ним метаданными называется интерфейсом.
У любого USB-устройства есть буфер номер ноль — это конечная точка туннеля по умолчанию, который используется для управляющих данных. Но как хост узнаёт, сколько у устройства есть ещё буферов и какого они типа? Для этого используются разные дескрипторы, отправляемые по особым запросам по туннелю по умолчанию. Они могут быть стандартными для всех, особыми для конкретных классов устройств, или проприетарными.
Дескрипторы составляют иерархию, которую можно посмотреть утилитами типа lsusb. Наверху сидит дескриптор устройства, где содержится Vendor ID (VID) и Product ID (PID). Эта пара уникальная для каждого устройства, по ней система находит нужный драйвер. У устройства может быть несколько конфигураций, каждое со своим интерфейсом (например, принтер, сканер и факс в МФУ). Но обычно определяется одна конфигурация с одним интерфейсом. Они описываются соответствующими дескрипторами. У каждой конечной точки есть дескриптор, содержащий её адрес (число), направление (ввод или вывод) и тип передачи.
У спецификаций классов есть свои типы дескрипторов. Спецификация USB HID ожидает передачу данных в виде «отчётов», которые отправляются и принимаются по буферу управления или прерываний. Эти дескрипторы определяют формат отчёта (к примеру,»1 поле длиной 8 бит») и то, как его надо использовать («офсет в направлении Х»). Поэтому HID-устройство описывает само себя и его может поддерживать универсальный драйвер (usbhid в Linux). Иначе пришлось бы писать свой драйвер для каждой мыши.
Не буду пытаться описывать в нескольких абзацах сотни страниц спецификаций. Интересующихся отправляю к книге O«Reilly «USB in a Nutshell», бесплатно лежащей по адресу www.beyondlogic.org/usbnutshell. Займёмся-ка лучше делом.
Разбираемся с разрешениями По умолчанию с USB-устройствами можно работать только из-под рута. Чтобы не запускать таким образом тестовую программу, добавим правило udev: SUBSYSTEM==«usb», ATTRS{idVendor}==»0a81», ATTRS{idProduct}==»0702», GROUP=«INSERT_HERE», MODE=»0660» Вставьте имя группы, к которой принадлежит ваш пользователь, и добавьте это в /lib/udev/rules.d/99-usbcar.rules.
Под капотом Посмотрим, как выглядит машинка по USB. lsusb — инструмент для подсчёта устройств и декодирования их дескрипторов. Входит в комплект usbutils. [val@y550p ~]$ lsusb Bus 002 Device 036: ID 0a81:0702 Chesen Electronics Corp. Bus 002 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub … Машинка — это Device 036 (чтобы быть уверенным, её можно отсоединить и снова запустить lsusb). Поле ID — это пара VID: PID. Для чтения дескрипторов запустите lsusb -v:
[val@y550p ~]$ lsusb -vd 0a81:0702 Bus 002 Device 036: ID 0a81:0702 Chesen Electronics Corp. Device Descriptor: idVendor 0×0a81 Chesen Electronics Corp. idProduct 0×0702 … bNumConfigurations 1 Configuration Descriptor: … Interface Descriptor: … bInterfaceClass 3 Human Interface Device … iInterface 0 HID Device Descriptor: … Report Descriptors: ** UNAVAILABLE ** Endpoint Descriptor: … bEndpointAddress 0×81 EP 1 IN bmAttributes 3 Transfer Type Interrupt … Стандартная иерархия. Как и у большинства устройств, у неё только одна конфигурация и интерфейс. Можно заметить одну конечную точку interrupt-in (кроме точки по умолчанию 0, которая есть всегда, и поэтому не выводится в списке). Поле bInterfaceClass сообщает о том, что это устройство HID. Это хорошо — протокол общения с HID открыт. Казалось бы, прочтём дескриптор отчётов, чтобы понять их формат и использование, и дело в шляпе. Однако у него стоит пометочка ** UNAVAILABLE **. ЧЗН? Поскольку машинка — устройство HID, драйвер usbhid присвоил её себе, но не знает, что с ней делать. Надо отвязать его от управления ею.
Для начала надо найти адрес шины. Переподключим её, запустим dmesg | grep usb, и посмотрим в последнюю строчку, начинающуюся с usb X-Y.Z:. X, Y и Z — целые числа, уникальным образом определяющие порты на хосте. Затем запустим
[root@y550p ~]# echo -n X-Y.Z:1.0 > /sys/bus/usb/drivers/usbhid/unbind 1.0 — это конфигурация и интерфейс, которые должен отпустить драйвер usbhid. Чтобы подвязать всё обратно, запишите то же самое в /sys/bus/usb/drivers/usbhid/bind.
Теперь поле Report descriptor выдаёт информацию:
Report Descriptor: (length is 52) Item (Global): Usage Page, data= [ 0xa0 0xff ] 65440 (null) Item (Local): Usage, data= [ 0×01 ] 1 (null) … Item (Global): Report Size, data= [ 0×08 ] 8 Item (Global): Report Count, data= [ 0×01 ] 1 Item (Main): Input, data= [ 0×02 ] 2 … Item (Global): Report Size, data= [ 0×08 ] 8 Item (Global): Report Count, data= [ 0×01 ] 1 Item (Main): Output, data= [ 0×02 ] 2 … Задано два отчёта. Один читает с устройства (ввод), второй пишет (вывод). Оба размером в байт. Однако их использование не очевидно. Для сравнения, вот как выглядит дескриптор отчёта для мышки (не весь, но главные строчки):
Report Descriptor: (length is 75) Item (Global): Usage Page, data= [ 0×01 ] 1 Generic Desktop Controls Item (Local): Usage, data= [ 0×02 ] 2 Mouse Item (Local): Usage, data= [ 0×01 ] 1 Pointer Item (Global): Usage Page, data= [ 0×09 ] 9 Buttons Item (Local): Usage Minimum, data= [ 0×01 ] 1 Button 1 (Primary) Item (Local): Usage Maximum, data= [ 0×05 ] 5 Button 5 Item (Global): Report Count, data= [ 0×05 ] 5 Item (Global): Report Size, data= [ 0×01 ] 1 Item (Main): Input, data= [ 0×02 ] 2 Тут всё ясно. С машинкой — непонятно, и нам надо догадаться об использовании битов самостоятельно.
Небольшой бонус Большинство радиоуправляемых игрушек просты и используют стандартные приёмники, работающие на одинаковых частотах. Значит, нашу программу можно будет использовать для управления другими игрушками, кроме этой машинки.Работа для детектива При анализе сетевого трафика используют снифер. И в нашем случае такая штука пригодится. Бывают специальные USB-мониторы для коммерческого использования, но для нашей задачи подойдёт и Wireshark.Настроим перехват USB в Wireshark. Сначала разрешим мониторинг USB в ядре. Загрузим модуль usbmon:
[root@y550p ~]# modprobe usbmon Подмонтируем особую файловую систему debugfs:
[root@y550p ~]# mount -t debugfs none /sys/kernel/debug Появится директория /sys/kernel/debug/usb/usbmon, которую можно использовать для записи трафика простыми средствами оболочки:
[root@y550p ~]# ls /sys/kernel/debug/usb/usbmon 0s 0u 1s 1t 1u 2s 2t 2u Там лежат файлы с загадочными именами. Целое число — номер шины (первая часть адреса шины USB); 0 означает все шины на хосте. s — statistics, t — transfers, u — URBs (USB Request Blocks логические сущности, представляющие происходящие транзакции). Чтобы сохранить все передачи на шине 2, введите:
[root@y550p ~]# cat /sys/kernel/debug/usb/usbmon/2t ffff88007d57cb40 296194404 S Ii:036:01 -115 1 < ffff88007d57cb40 296195649 C Ii:036:01 0 1 = 05 ffff8800446d4840 298081925 S Co:036:00 s 21 09 0200 0000 0001 1 = 01 ffff8800446d4840 298082240 C Co:036:00 0 1 > ffff880114fd1780 298214432 S Co:036:00 s 21 09 0200 0000 0001 1 = 00 Для нетренированного глаза тут ничего непонятно. Хорошо, что Wireshark будет декодировать данные.
Теперь нам нужна Windows, которая будет работать с оригинальным драйвером. Лучше всего установить всё в (с Oracle Extension Pack). Убедитесь, что VirtualBox может использовать устройство, и запустите KeUsbCar, которая управляет машинкой в Windows. Запустите Wireshark, чтобы посмотреть, какие команды драйвер отправляет на устройство. На первом экране выберите интерфейс usbmonX, где X — это шина, к которой подключена машинка. Если Wireshark запускается не из-под рута, убедитесь, что узлы /dev/usbmon* имеют соответствующие разрешения.
Нажмём в KeUsbCar кнопочку Forward. Wireshark перехватит несколько исходящих управляющих пакетов. На скриншоте отмечен тот, который нужен нам. Судя по параметрам, это запрос SET_REPORT (bmRequestType = 0×21, bRequest = 0×09), который обычно используется, чтобы изменить статус устройства — такого, как лампочки на клавиатуре. Согласно тому Report Descriptor, что мы видели, длина данных составляет 1 байт, и сам отчёт содержит 0×01 (также подсвечено).
Нажатие кнопки Right выливается в похожий запрос. Но отчёт уже содержит 0×02. Можно догадаться, что это означает направление движение. Таким же образом выясняем, что 0×04 — это правый реверс, 0×08 — задний ход, и т.д. Правило простое: код направления — это двоичная единичка, сдвинутая влево на позицию кнопки в интерфейсе KeUsbCar, если считать их по часовой стрелке.
Также можно отметить периодические запросы прерываний от Endpoint 1 (0×81, 0×80 означает, что это точка ввода; 0×01 это её адрес). Что это? Кроме кнопок, у KeUsbCar есть индикатор заряда, так что это, возможно, данные по батарее. Их значение не меняется (0×05), если машина не выезжает из гаража. В противном случае запросов прерываний не происходит, но они возобновляются, если мы ставим её обратно. Тогда, видимо, 0×05 означает «идёт зарядка» (игрушка простая, поэтому уровень зарядки не передаётся). Когда батарея зарядится, прерывание начнёт возвращать 0×85 (0×05 с установленным 7 битом). Видимо, 7 бит означает «заряжено». Что делают биты 0 и 2, которые составляют 0×05, пока неясно.
Пишем почти настоящий драйвер Заставить программу работать с устройством, которое ранее не поддерживалось — это хорошо, но иногда нужно сделать так, чтобы с ним работала и остальная система. Это значит, надо делать драйвер, а это требует программирования на уровне ядра (http://www.linuxvoice.com/be-a-kernel-hacker/), и вам это вряд ли сейчас нужно. Но возможно, нам удастся обойтись и без этого, если речь идёт про USB.Если у вас есть USB-сетевуха, можно использовать TUN/TAP для подключения программы PyUSB в сетевой стек Linux. Интерфейсы TUN/TAP работают как обычные сетевые, с именами типа tun0 или tap1, но через них все пакеты становятся доступны в узле /dev/net/tun. Модуль pytun делает работу с TUN/TAP простой. Быстродействие страдает, но можно переписать программу на С с использованием libusb.
Ещё один кандидат — USB-дисплей. В Linux есть модуль vfb, который позволяет обращаться к фреймбуферу как к /dev/fbX. Можно использовать ioctls, чтобы перенаправить консоль на него, и закачивать содержимое /dev/fbX на USB-устройство. Это тоже не быстро, но ведь вы не собираетесь играть в 3D-шутеры через USB.
Пишем код Сделаем такую же программу, как для Windows. 6 стрелочек и уровень зарядки, который мигает, когда машинка заряжается. Код лежит на GitHub github.com/vsinitsyn/usbcar.pyКак нам работать в USB под Linux? Это возможно делать из пространства пользователя при помощи библиотеки libusb. Она написана на С и требует хороших знаний USB. Простая альтернатива — PyUSB. Для интерфейса пользователя я использовал PyGame.
Исходники PyUSB скачайте с github.com/walac/pyusb, и установите через setup.py. Ещё вам понадобится установить библиотеку libusb. Поместим функциональность для управления машиной в класс с оригинальным названием USBCar.
import usb.core import usb.util class USBCar (object): VID = 0×0a81 PID = 0×0702 FORWARD = 1 RIGHT = 2 REVERSE_RIGHT = 4 REVERSE = 8 REVERSE_LEFT = 16 LEFT = 32 STOP = 0 Импортируем два главных модуля PyUSB и вставляем значения для управления машинкой, которые мы вычислили при просмотре трафика. VID и PID — это id машинки, взятые из вывода lsusb.
def __init__(self): self._had_driver = False self._dev = usb.core.find (idVendor=USBCar.VID, idProduct=USBCar.PID) if self._dev is None: raise ValueError («Device not found») Функция usb.core.find () ищет устройство по его ID. Подробности см. github.com/walac/pyusb/blob/master/docs/tutorial.rst
if self._dev.is_kernel_driver_active (0): self._dev.detach_kernel_driver (0) self._had_driver = True Мы отвязываем драйвер ядра, как мы делали в случае с lsusb. 0 — номер интерфейса. По выходу из программы его надо привязать обратно через release (), если он был активен. Поэтому мы запоминаем начальное состояние в self._had_driver.
self._dev.set_configuration () Запускаем конфигурацию. Этот код эквивалентен следующему коду, который PyUSB скрывает от программиста:
self._dev.set_configuration (1) usb.util.claim_interface (0) def release (self): usb.util.release_interface (self._dev, 0) if self._had_driver: self._dev.attach_kernel_driver (0) Этот метод надо вызвать перед завершением программы. Мы отпускаем использовавшийся интерфейс и присоединяем драйвер ядра обратно.
Передвижение машинки:
def move (self, direction): ret = self._dev.ctrl_transfer (0×21, 0×09, 0×0200, 0, [direction]) return ret == 1 direction — одно из значений, определённых в начале класса. ctrl_transfer () передаёт управляющие команды. Данные передаются как строка или как список. Возвращает метод количество записанных байт. Поскольку у нас всего один байт, мы возвращем True в этом случае, и False в ином.
Метод для статуса батареи:
def battery_status (self): try: ret = self._dev.read (0×81, 1, timeout=self.READ_TIMEOUT) if ret: res = ret.tolist () if res[0] == 0×05: return 'charging' elif res[0] == 0×85: return 'charged' return 'unknown' except usb.core.USBError: return 'out of the garage' Метод read () принимает адрес конечной точки и количество байт для чтения. Тип передачи определяется конечной точкой и хранится в дескрипторе. Также мы задаём нестандартное время таймаута, чтобы программа работала быстрее. Device.read () возвращает массив, который мы конвертируем в список. Мы проверяем его первый байт, чтобы определить статус зарядки. Если машинка не в гараже, то вызов read () не выполнится, и выбросит ошибку usb.core.USBError. Мы предполагаем, что эта ошибка происходит именно из-за этого. В остальных случаях мы возвращаем статус «unknown».
Класс UI инкапсулирует интерфейс пользователя. Пройдёмся по основным вещам. Главный цикл находится в UI.main_loop (). Мы задаём фон с картинкой, показываем уровень заряда, если машинка в гараже, и рисуем кнопки управления. Затем ждём события — если это клик, то двигаем машинку в заданном направлении через USBCar.move ().
Вся программа, включая GUI, занимает чуть больше 200 строк. Неплохо для устройства без документации.
Конечно, мы специально взяли довольно простое устройство. Но в мире есть довольно много схожих с нашим устройств, и многие используют протоколы, не сильно отличающиеся от того, что мы расковыряли. Реверс-инжениринг сложного устройства — задача непростая, но уже сейчас вы можете добавить в Linux поддержку какой-нибудь безделушки вроде устройства, сообщающего о полученном e-mail. Если это и не сильно полезно — то, по крайней мере, это интересно.