
Пару месяцев назад я купил Nanoleaf Pegboard Desk Dock — последнее слово в технологиях USB-хабов с RGB-светодиодами и крючками для устройств. К сожалению, это чудо инженерной мысли поддерживает только гейминговые операционные системы — Windows и macOS, поэтому возникла необходимость в драйвере для Linux.
В своих постах я уже настраивал Windows VM с пробросом USB и пытался выполнить реверс-инжиниринг официальных драйверов. При этом я задумался, а нельзя ли написать производителю и попросить у него спецификации или документацию его протокола. К моему удивлению, техподдержка Nanoleaf ответила мне всего через четыре часа, предоставив полное описание протокола, используемого Desk Dock, а также полосами RGB-светодиодов. Документация по большей мере подтвердила то, что я обнаружил самостоятельно, но также я нашёл в ней пару других мелких подробностей (например, управление питанием и яркостью), которые были мне неизвестны.
Сегодня мы попробуем написать драйвер на основании протокола (который я изучил реверс-инжинирингом), параллельно сверяясь с официальной документацией. Однако здесь есть одна небольшая проблема: раньше я ни разу не писал драйверов для устройств под Linux, а с USB-устройствами взаимодействовал только как пользователь.
Начинаем с нуля
В большинстве дистрибутивов Linux есть lsusb
— простая утилита, создающая список всех подключённых к системе USB-устройств. Я понятия не имел, с чего начать, поэтому подумал, что будет неплохо запустить её и проверить, есть ли моё устройство в списке.
$ lsusb
<вырезано>
Bus 001 Device 062: ID 37fa:8201 JW25021301515 Nanoleaf Pegboard Desk Dock
Отлично, в списке оно есть. Но как ядро узнало, что я подключил именно «Nanoleaf Pegboard Desk Dock»? Ядро (предположительно) не обладает знаниями о существовании этого устройства, однако как только я подключаю его к компьютеру, на него подаётся питание, оно включается и распознаётся ядром.
Как оказалось, драйвер уже есть! Просто он очень тупой. Если запустить lsusb
в режиме подробных данных и запросить информацию об этом конкретном устройстве, то мы узнаем о нём много подробностей:
$ lsusb -d 37fa:8201 -v
Bus 001 Device 091: ID 37fa:8201 JW25021301515 Nanoleaf Pegboard Desk Dock
Negotiated speed: Full Speed (12Mbps)
Device Descriptor:
bLength 18
bDescriptorType 1
bcdUSB 1.10
bDeviceClass 0 [unknown]
bDeviceSubClass 0 [unknown]
bDeviceProtocol 0
bMaxPacketSize0 64
idVendor 0x37fa JW25021301515
idProduct 0x8201 Nanoleaf Pegboard Desk Dock
bcdDevice 1.09
iManufacturer 1 JW25021301515
iProduct 2 Nanoleaf Pegboard Desk Dock
iSerial 3 <вырезано>
bNumConfigurations 1
Configuration Descriptor:
bLength 9
bDescriptorType 2
wTotalLength 0x0029
bNumInterfaces 1
bConfigurationValue 1
iConfiguration 4 Nanoleaf Pegboard Desk Dock
bmAttributes 0xa0
(Bus Powered)
Remote Wakeup
MaxPower 70mA
Interface Descriptor:
bLength 9
bDescriptorType 4
bInterfaceNumber 0
bAlternateSetting 0
bNumEndpoints 2
bInterfaceClass 3 Human Interface Device
bInterfaceSubClass 0 [unknown]
bInterfaceProtocol 0
iInterface 5 Nanoleaf Pegboard Desk Dock
HID Device Descriptor:
bLength 9
bDescriptorType 33
bcdHID 1.00
bCountryCode 0 Not supported
bNumDescriptors 1
bDescriptorType 34 (null)
wDescriptorLength 34
Report Descriptors:
** UNAVAILABLE **
Endpoint Descriptor:
bLength 7
bDescriptorType 5
bEndpointAddress 0x82 EP 2 IN
bmAttributes 3
Transfer Type Interrupt
Synch Type None
Usage Type Data
wMaxPacketSize 0x0040 1x 64 bytes
bInterval 1
Endpoint Descriptor:
bLength 7
bDescriptorType 5
bEndpointAddress 0x02 EP 2 OUT
bmAttributes 3
Transfer Type Interrupt
Synch Type None
Usage Type Data
wMaxPacketSize 0x0040 1x 64 bytes
bInterval 1
Device Status: 0x0000
(Bus Powered)
Здесь куча информации, так что нам необходимо краткое введение в USB.
Краткое введение в USB
Спецификация USB длинная и сложная, в основном она предназначена для низкоуровневых реализаций (со стороны разработчиков ядра, производителей устройств и так далее). Разумеется, если вам нравится скучать, можете прочитать её. Но, к счастью, какая-то добрая душа изложила самое важное в статье USB in a NutShell.
Краткое изложение этого краткого изложения: у USB-устройства может быть несколько конфигураций, которые обычно объясняют требования к питанию устройства. У большинства устройств конфигурация только одна.
Каждая из этих конфигураций может иметь несколько интерфейсов. Поэтому фотокамера, например, может также служить устройством для хранения файлов и веб-камерой.
Наконец, у каждого интерфейса может быть несколько конечных точек, описывающих способ передачи данных. Например, фотокамера, используемая в качестве веб-камеры, может выполнять «изохронную» (непрерывную) передачу и групповую передачу при копировании файлов изображений.
Вернёмся к нашему устройству: мы видим, что у неё есть только один интерфейс, а именно Human Interface Device. HID — это класс USB-устройств, к которому относятся клавиатуры, мыши и геймпады; каждая из этих категорий — это отдельный подкласс. Ядро содержит стандартный драйвер для USB HID (вот он).
Именно поэтому разработчикам ядра не нужно писать драйверы для каждой модели клавиатуры и мыши. Производители помечают свои устройства одним из известных подклассов HID, а затем используют стандартный протокол для реализации их функциональности.
Любопытный факт, которым можно удивлять девушек
Документ о стандартных реализациях HID состоит из 443 страниц. А вы знали, что USB-клавиатура (keyboard), согласно USB-IF, состоит из не менее чем 103 кнопок, а всё, у чего кнопок меньше — это клавишная панель (keypad)?
К сожалению, для устройств с RGB-светодиодами спецификации HID не существует (на самом деле, есть спецификация «LED», но она предназначена в основном для индикаторов состояния, а не для цветных светодиодов), поэтому наше устройство — это старый добрый стандартный HID с подклассом интерфейса 0
. Это значит, что ядро распознаёт его и правильно подаёт питание, но не знает, что с ним делать.
На этом этапе у нас есть два варианта:
Можно написать драйвер ядра, отвечающий стандарту ядра, и раскрыть светодиоды как три устройства (по одному на цвет) в
/sys/class/leds
. Перспектива общения с разработчиками ядра меня пугает (хоть я уже взрослый), но даже если бы не пугала, я сомневаюсь в полезности добавления в ядро драйверов для очень нишевого устройства. К тому же мне кажется, что/sys/class/leds
предназначены для светодиодов состояния, а не для геймерских цветов.Можно написать при помощи libusb драйвер пользовательского пространства, определив таким образом собственный способ управления светодиодами и снизив планку качества с «если накосячишь, тебя пошлёт в крепких выражениях Линус Торвальдс» до «пофиг, погнали».
Я понятия не имею, что делаю, поэтому выбрал второй вариант, но если кто-то из вас осмелится и выберет первый, то напишите мне об этом, я распечатаю ваше фото и повешу в рамке на стену.
Побочный квест: правила udev
Для выполнения самых интересных действий в Linux нужно быть root
. То же самое относится и к общению с USB-устройствами. Всегда можно запускать драйверы под root
, обойдя таким образом проблему. Но все мы знаем, что это плохое решение. А если я начну распространять этот драйвер, то большинство пользователей будет ожидать, что он работает без повышения привилегий.
В общем случае для управления обработчиками аппаратных событий Linux использует udev
. На этот раз я избавлю вас от долгих историй и просто покажу нужное магическое заклинание: чтобы устройство стало доступным для пользователей, нужно создать файл /etc/udev/rules.d/70-pegboard.rules
со следующим содержимым:
ACTION=="add", SUBSYSTEM=="usb", DRIVERS=="usb", ATTRS{idVendor}=="37fa", ATTRS{idProduct}=="8201", MODE="0770", TAG+="uaccess"
где ATTRS{idVendor}
и ATTRS{idProduct}
— это ID производителя и изделия, взятые из lsusb
, а TAG+="uaccess"
— это заклинание, дающее текущему активному пользователю разрешения на управление устройством. Теперь отключим устройство и подключим его снова.
Прочитайте это, если пользуетесь NixOS, и пропустите, если иногда всё же выходите на улицу
Можно назвать файл .rules
так, как вам угодно, но, разумеется, по алфавиту имя должно идти перед 73
. Почему? Да просто так. Это создаёт интересную проблему в NixOS, которая в своей неизбывной мудрости позволяет добавлять свои правила единственным образом, записывающим всё в 99-local.rules. Решить это можно так: создать в нужном месте пакет, определяющий правило, а затем расширить services.udev.packages
этим новым пакетом. К счастью, это легко можно сделать при помощи pkgs.writeTextFile
:
services.udev.packages = [
(pkgs.writeTextFile {
name = "pegboard_udev";
text = ''
ACTION=="add", SUBSYSTEM=="usb", DRIVERS=="usb", ATTRS{idVendor}=="37fa", ATTRS{idProduct}=="8201", MODE="0770", TAG+="uaccess"
'';
destination = "/etc/udev/rules.d/70-pegboard.rules";
})
];
Пишем простой драйвер
Ладно, хватит болтовни. Давайте начнём с простого двоичного файла на Rust и сразу же добавим крейт rusb
, который будет использоваться, как привязка к libusb
.
cargo new gamer-driver
cd gamer-driver
cargo add rusb
Дальше можно попробовать получить дескриптор устройства и базовую информацию о нём, как это делает lsusb
. Всё это довольно неплохо описано в readme крейта, поэтому вдаваться в подробности я не буду. Нам нужен Context
, дающий удобный метод open_device_with_vid_pid
, при помощи которого можно получить дескриптор устройства.
use rusb::{Context, UsbContext};
const VENDOR: u16 = 0x37fa;
const DEVICE: u16 = 0x8201;
fn main() {
let context = Context::new().expect("cannot open libusb context");
let device = context
.open_device_with_vid_pid(VENDOR, DEVICE)
.expect("cannot get device");
let descriptor = device
.device()
.device_descriptor()
.expect("cannot describe device");
println!("{descriptor:#?}");
}
$ cargo run
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s
Running `target/debug/gamer-driver`
DeviceDescriptor {
bLength: 18,
bDescriptorType: 1,
bcdUSB: 272,
bDeviceClass: 0,
bDeviceSubClass: 0,
bDeviceProtocol: 0,
bMaxPacketSize: 64,
idVendor: 14330,
idProduct: 33281,
bcdDevice: 265,
iManufacturer: 1,
iProduct: 2,
iSerialNumber: 3,
bNumConfigurations: 1,
}
Радости отладки
Теперь, когда у нас есть доступ к устройству, можно попробовать написать для него простую полезную нагрузку. Для этого сначала нужно запросить интерфейс. Вспомним, что интерфейсы — это, по сути, возможности устройства, а при помощи lsusb
мы выяснили, что у устройства есть один интерфейс с ID (bInterfaceNumber
) 0
. К счастью, у Device
есть простой метод claim_interface
.
// ...
const INTERFACE: u8 = 0x0;
fn main() {
// ...
device
.claim_interface(INTERFACE)
.expect("unable to claim interface");
}
$ cargo run
Compiling gamer-driver v0.1.0 (/home/ivan/Code/gamer-driver)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.21s
Running `target/debug/gamer-driver`
thread 'main' panicked at src/main.rs:15:10:
unable to claim interface: Busy
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Ой.
Итак, мы столкнулись с весёлым миром сообщений об ошибках libusb
. Это сообщение из четырёх символов, на самом деле, на удивление информативно: мы могли получить сообщение, в котором написано только Io
; в таком случае удачи нам с отладкой. В общем случае, Busy
означает, что кто-то уже держит устройство открытым, поэтому мы не можем ничего с ним делать. Однако никто нам не скажет, кто же держит его открытым.
Секрет заключается в том, что устройство, разумеется, открыто ядром. Это тот стандартный драйвер, о котором я говорил выше. И чтобы решить эту проблему, нужно освободить драйвер ядра, который пока активен для устройства.
Для этого требуется доступ на запись к устройству, поэтому, если вы не проделали описанный выше танец с бубном для udev
, то готовьтесь добавлять ко всем будущим вызовам своего драйвера sudo
.
fn main() {
// ...
if device
.kernel_driver_active(INTERFACE)
.expect("cannot get kernel driver")
{
device
.detach_kernel_driver(INTERFACE)
.expect("cannot detach kernel driver");
}
device
.claim_interface(INTERFACE)
.expect("unable to claim interface");
}
Стоит отметить, что драйвер ядра не будет повторно подключаться автоматически, поэтому если вам по какой-то причине захочется его вернуть, придётся вызывать device.attach_kernel_driver(INTERFACE)
.
Передаём данные на устройство
Ну теперь-то, наверно, мы можем записывать байты на устройство?
Уже почти да! Если попробовать наивно начать вводить что-то типа device.write
, то IDE предложит три варианта: write_bulk
, write_control
и write_interrupt
. Они соответствуют трём из четырёх возможных типов конечных точек, поддерживаемых стандартом USB. Повторюсь, что в USB in a NutShell подробно объясняется, что означает каждый из типов конечных точек. К счастью, мы можем не вдаваться в подробности реализации, потому что можно снова обратиться к показанным выше данным lsusb
:
Endpoint Descriptor:
bEndpointAddress 0x82 EP 2 IN
bmAttributes 3
Transfer Type Interrupt
Usage Type Data
wMaxPacketSize 0x0040 1x 64 bytes
bInterval 1
Endpoint Descriptor:
bEndpointAddress 0x02 EP 2 OUT
bmAttributes 3
Transfer Type Interrupt
Usage Type Data
wMaxPacketSize 0x0040 1x 64 bytes
bInterval 1
В терминологии USB IN
— это всегда то, что устройство отправляет хосту, а OUT
— всегда то, что хост отправляет устройству. По сути, поскольку этот интерфейс имеет две конечные точки и только одна из них — это конечная точка OUT
, можно разумно предположить, что мы видим write_interrupt
в конечной точке 0x02
. Буквально через пару минут тонкости конечных точек Interrupt вызовут у нас проблемы, но пока мы можем забыть о них.
В рамках тестирования я хочу сделать так, чтобы устройство горело красным цветом. Согласно моим предыдущим исследованиям, это значит, что нужно отправить на конечную точку 0x02
значение 02 00 c0
, за которыми следует 64 повторения 0f ff 0f
. Кроме того, нужно учесть, что rusb
раскрывает только блокирующий API libusb
, поэтому нам также нужно определить таймаут, после которого libusb
прекратит попытки и вернёт ошибку.
Ничего не перепутайте
Наверно, если вы читаете эту статью, то знаете, что запись произвольных данных в паттернах, которые мы разгадали на основании анализа пакетов — не самое безопасное занятие в жизни. Но всё равно следует напомнить, что описанные ниже действия могут нанести неустранимый урон устройству или, что хуже, вам. Помните, что экспериментировать стоит только с тем, что вы можете заменить (имеется в виду и оборудование, и конечности). Я вас предупредил, не обвиняйте потом меня.
use std::time::Duration;
// ...
const ENDPOINT_OUT: u8 = 0x02;
const ENDPOINT_IN: u8 = 0x82;
const TIMEOUT: Duration = Duration::from_secs(1);
fn main() {
// ...
device
.claim_interface(INTERFACE)
.expect("unable to claim interface");
let command: [u8; 3] = [0x02, 0x00, 0xc0];
let color: [u8; 3] = [0x0f, 0xff, 0x0f];
let body: Vec<u8> = command
.into_iter()
.chain(color.into_iter().cycle().take(192))
.collect();
device
.write_interrupt(ENDPOINT_OUT, &body, TIMEOUT)
.expect("unable to write to device");
}
$ cargo run
Compiling gamer-driver v0.1.0 (/home/ivan/Code/gamer-driver)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.24s
Running `target/debug/gamer-driver`
Вот и всё, теперь Pegboard горит красным цветом! Нам не нужно беспокоиться о ручном разделении пакетов и о внутренней реализации, достаточно открыть конвейер и выполнить в него запись!
Давайте запустим всё заново, чтобы убедиться, что это не случайность!
Итак, о прерываниях…
Если вы повторяете всё, что написано в посте, и запустили тот же двоичный файл во второй раз, то заметили, что прошивка Pegboard бесцеремонно вываливается, а вскоре после этого возвращается к своей анимации по умолчанию. И если вернуться к нашему исходному перехвату пакетов (или к официальной документации), то причина становится очевидной: устройство отправляет нам ответ, но мы не читаем его.
Оказывается, «прерывания» названы так не случайно и нам, вероятно, стоит обрабатывать их в процессе их поступления. Однако в спецификации USB написано, что хост должен выполнять опрос на наличие прерываний. Устройство не может выполнять прерывания на хосте само по себе.
В случае нашего простого «драйвера» это означает, что мы должны опрашивать устройство сразу после выполнения записи на него. К счастью, у rusb
есть метод read_interrupt
, а мы уже определили константу ENDPOINT_IN
. Давайте сделаем это:
fn main() {
// ...
device
.write_interrupt(ENDPOINT_OUT, &body, TIMEOUT)
.expect("unable to write to device");
let mut buf = [0_u8; 64];
device
.read_interrupt(ENDPOINT_IN, &mut buf, TIMEOUT)
.expect("unable to read from device");
dbg!(buf);
}
Ещё немного бесполезной информации о спецификации USB
Возможно, вы заметили паттерн — конечная точка
OUT
с ID0x02
, или0b0000_0010
, имеет соответствующийIN
с ID0x82
, или0b1000_0010
. На самом деле, это определено в спецификации!
Выполняя чтение, мы видим, что содержимое buf
равно [130, 0, 1, 0...]
, что соответствует 0x82 0x00 0x01
А поскольку теперь мы каждый раз очищаем буфер прерываний, можно выполнять этот двоичный файл многократно, чтобы определить один цвет устройства. Отлично!
Улучшаем систему
Но, разумеется, это не то, чего мы хотели. Устройство может создавать и другие прерывания. Например, у Desk Dock есть одна кнопка, на которую можно нажимать один или два раза или же удерживать её; в каждом из этих случаев будет создано отдельное прерывание. То есть на самом деле нам нужна фоновая задача, которая будет активно опрашивать устройство на наличие прерываний и обрабатывать их в процессе их получения.
И тут можно дать себе волю с асинхронным Rust, tokio
, каналами и прочим. Именно так и нужно писать реальный, серьёзный драйвер. Но чтобы не связываться со сложностями асинхронного Rust, давайте просто воспользуемся std::thread::scope
.
Также мы настроим таймаут считывания прерываний, сделав его равным 1 миллисекунде, как это требуется для устройства (значение bInterval
в данных lsusb
). Это не значит, что мы будем получать прерывание каждую миллисекунду, устройство просто может отправлять прерывания с такой частотой. Если устройство ничего не отправляет (то есть мы получаем Err(Timeout)
), то можно просто продолжить выполнение цикла.
Если соединить всё вместе, то это может выглядеть примерно так:
use std::thread;
// ...
const WRITE_TIMEOUT: Duration = Duration::from_secs(1);
const READ_TIMEOUT: Duration = Duration::from_millis(1);
fn main() {
// ...
thread::scope(|s| {
s.spawn(|| {
// Делаем только один раз, затем завершаем поток.
device
.write_interrupt(ENDPOINT_OUT, &body, WRITE_TIMEOUT)
.expect("unable to write to device");
});
s.spawn(|| {
// Считываем прерывания, пока не нажали Ctrl-C.
loop {
let mut buf = [0_u8; 64];
match device.read_interrupt(ENDPOINT_IN, &mut buf, READ_TIMEOUT) {
Ok(_) => println!("Interrupt: {}", buf[0]),
Err(rusb::Error::Timeout) => continue,
Err(e) => panic!("{e:?}"),
}
}
});
});
}
$ cargo run
Compiling gamer-driver v0.1.0 (/home/ivan/Code/gamer-driver)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.23s
Running `target/debug/gamer-driver`
Interrupt: 130
^C
И это работает! Разумеется, мы больше не отправляем кадры цвета на устройство, поэтому не получим других прерываний, но теперь у нас есть два потока, один из которых можно использовать для изменения отображаемых цветов, а другой для считывания прерываний.
У этого устройства есть свои особенности: похоже, оно требует стабильного потока кадров цвета, и в противном случае возвращается к «офлайн-режиму», не получая никаких новых кадров от хоста, а яркость первого кадра существенно ниже, чем яркость будущих кадров. Кроме того, несмотря на то, что утверждается в официальной документации протокола, цвета, похоже, записываются в формате GRB вместо RGB, а если сделать устройство слишком ярким, то оно через пару секунд выполнит перезагрузку. Наверно, всё это можно считать частью удовольствия от кодинга.
Однако этот небольшой proof of concept показал, что писать простые драйверы устройств не так сложно, и что довольно многого можно добиться всего пятьюдесятью строками кода. В течение следующих нескольких недель я надеюсь усовершенствовать свой proof of concept, создать для него небольшой GUI, упаковать всё и поделиться с теми двумя пользователями Linux, у которых есть то же устройство. И я очень рад тому, что обучился основам реверс-инжиниринга простого драйвера USB-устройства, воспользовавшись ими для написания собственного драйвера. Даже несмотря на то, что можно было не заморачиваться этим и просто попросить спецификацию у производителя.
vdudouyt
На самом деле, там вполне мог бы стоять и "нулевой" класс (он же vendor-specific, он же 0xff), и тогда коммуникация с прибором через libusb была бы еще проще. Однако, тогда под одной не в меру популярной ОС пришлось бы либо писать драйвера уровня ядра либо ставить не очень уж там прижившийся драйвер от WinUSB. А вот перекидываться произвольными данными с устройствами классами HID там - без проблем. Что и объясняет популярность такого подхода даже среди прошивалок чипов с Ali.