В любой системе, в частности, в роботе где есть USB-устройства, постоянно борются две силы.

Первая — стремление к хаосу.
Устройства подключаются и отключаются, инициализируются в разном порядке, получают временные имена вроде /dev/ttyUSB0, /dev/ttyUSB1, /dev/video0. Сегодня лидар — это ttyUSB0, завтра — уже ttyUSB1. Камера, которая вчера была video0, после перезагрузки вдруг становится video2.
Вторая — стремление к упорядочиванию.
Инженер хочет, чтобы устройство имело понятное и стабильное имя, чтобы конфигурации не приходилось переписывать после каждого ребута, а робот вел себя предсказуемо — на столе, в классе и на соревнованиях.
Пока в системе один USB-девайс, хаос почти незаметен.
Но как только появляется второй ESP32, лидар, камера — случайность начинает побеждать. Естественно, что это проявляется в роботе в самый неподходящий момент, например на презентации.
В этой статье я покажу, как перейти от гадания на TTY к принятию себя вызова:
закрепить USB-устройства по физическому порту, получить стабильные имена прямо в /dev (/dev/rplidar, /dev/esp32_drive, /dev/cam_left) и забыть о сюрпризах после перезагрузки.
Решение основано на стандартных механизмах Linux (by-path, systemd), хорошо масштабируется, не зависит от одинаковых устройств и отлично подходит для роботов под ROS2.
Если вы только начинаете работать с ROS2 или хотите системно разобраться, как устроено управление ROS2-роботом (ноды, топики, датчики, драйверы, launch-файлы), у меня есть бесплатный курс (да, полностью бесплатный) по ROS2, где эти темы разбираются пошагово: https://stepik.org/course/221157
Почему при использовании /dev/ttyUSB0 и /dev/video0 возникают «вопросики»?
/dev/ttyUSB0 и video0 — это не имена устройств, а временные номера, которые выдаёт система в зависимости от:
порядка и скорости инициализации USB;
наличия/отсутствия других устройств;
работы хабов;
переподключений.
Даже если сегодня лидар — это ttyUSB0, завтра он может стать ttyUSB1.
С камерами такая же история: «левая» и «правая» вдруг могут сменить, не побоюсь этого слова — взгляды, и ваш робот с удовольствием поедет, но в другую сторону.
Почему “классические” udev-правила часто не спасают
Самый популярный совет от «этих ваших» интернетов нейросетей: “сделай udev-правило”. Однако, они обучались и живут в каком-то идеальном мире, где каждое устройство пронумеровано, а если у вас несколько одинаковых устройств (например, 2–3 ESP32 на одном и том же USB-UART чипе), то их ID совпадают. Проблема не в udev как таковом, а в том, что без уникального серийника udev-правило вызывает неоднозначную ситуацию.
Да, можно пытаться выкручиваться матчингом по серийнику, интерфейсу, атрибутам… но в реальном роботе, как мне кажется, самое надёжное — привязаться к физическому порту.
Идея решения: by-path = физический USB-порт
Linux сам создаёт стабильные пути «по проводу»:
Для serial-устройств:
/dev/serial/by-path/...Для V4L2-камер:
/dev/v4l/by-path/...
Это не “красивые имена”, но они однозначно соответствуют физическому месту подключения (порт), и будут оставаться такими.
А мы поверх них создадим понятные ссылки (симлинки): /dev/rplidar, /dev/esp32_drive, /dev/cam_left…
Что будем делать
Находим by-path для каждого устройства (лидар, ESP32, камеры).
Создаём карту соответствий в
/etc/robot-devices.map.Пишем один небольшой скрипт, который создаёт симлинки в
/dev.Заводим systemd-сервис, который запускается на старте.
Шаг 1. Находим by-path для serial-устройств (лидар, ESP32)
Смотрим список:
ls -l /dev/serial/by-path/
Пример вывода:
platform-xhci-hcd.1-usb-0:1:1.0-port0 -> ../../ttyUSB0
platform-xhci-hcd.0-usb-0:2:1.0-port0 -> ../../ttyUSB1
⚠️ Часто вы увидите пары строк вида usb-... и usbv2-... для одного устройства.
На мой взгляд, стоит использовать путь без usbv2 (он обычно стабильнее).
Шаг 2. Находим by-path для камер (V4L2)
Камеры живут в другом месте:
ls -l /dev/v4l/by-path/
Пример:
platform-xhci-hcd.0-usb-0:1:1.0-video-index0 -> ../../video0
platform-xhci-hcd.0-usb-0:1:1.0-video-index1 -> ../../video1
В моем случае (стереокамера):
video-index0— левый сенсорvideo-index1— правый сенсор
⚠️ Если видите много строк вида platform-1000...codec или ...pisp... — это внутренние видеоустройства SoC (ISP/кодеки), их не используем для USB-камер.
Шаг 3. Смотрим свойства и диагностируем
Serial
udevadm info -n /dev/ttyUSB0 --query=property
Камера: v4l2-ctl
Утилиты для просмотра свойств камеры в системе может не быть:
sudo apt update
sudo apt install v4l-utils
Далее можно просмотреть список камер и их конфигурации:
v4l2-ctl --list-devices
v4l2-ctl -d /dev/video0 --all
Эти команды полезны для определения форматов/разрешений, но привязку делаем всё равно по by-path.
Шаг 4. Создаём «карту устройств» /etc/robot-devices.map
Почему /etc:
это стандартное место для системной конфигурации;
файл легко бэкапить и переносить между машинами;
конфигурация отделена от логики.
Создаём файл :
sudo nano /etc/robot-devices.map
Формат строки:
<type> <by-path> <final_name>
Где:
-
type:serial—/dev/serial/by-path/...v4l—/dev/v4l/by-path/...
by-path: путь, который вы определили в шагах 1 и 2final_name— имя, которое появится в/dev
Пример:
serial /dev/serial/by-path/platform-xhci-hcd.1-usb-0:1:1.0-port0 rplidar
Шаг 5. Скрипт, который создаёт симлинки в /dev
Скрипт создадим в директории:/usr/local/sbin:
sbin— системные утилиты;local— «наше», не перезатрётся обновлениями ОС.
Создаём:
sudo nano /usr/local/sbin/robot-dev-symlinks
Содержимое:
#!/usr/bin/env bash
set -euo pipefail
MAP_FILE="/etc/robot-devices.map"
OUT_DIR="/dev"
WAIT_SEC=10
while IFS= read -r line; do
[[ -z "${line// }" || "${line:0:1}" == "#" ]] && continue
type=$(echo "$line" | awk '{print $1}')
src=$(echo "$line" | awk '{print $2}')
name=$(echo "$line" | awk '{print $3}')
dst="$OUT_DIR/$name"
# ждём появления by-path (на старте система может инициализировать USB не мгновенно)
for ((i=0;i<WAIT_SEC*10;i++)); do
[[ -e "$src" ]] && break
sleep 0.1
done
[[ ! -e "$src" ]] && continue
ln -sfn "$src" "$dst"
echo "[robot-dev] $dst -> $(readlink -f "$src")"
done < "$MAP_FILE"
Делаем исполняемым:
sudo chmod +x /usr/local/sbin/robot-dev-symlinks
И запускаем вручную:
sudo /usr/local/sbin/robot-dev-symlinks
А затем быстро отфильтровать нужные строки из /dev (возможно, вам нужно будет поменять параметры отбора):
ls -l /dev | grep -E 'rplidar|esp32|cam'
Пояснение:
ls -l /devпоказывает всё в/dev;grep -Eвключает расширенные регулярные выражения;rplidar|esp32|camозначает “показать строки, где встречается rplidar или esp32 или cam”.
Шаг 6. Поднимаем всё через systemd-сервис
Почему systemd:
управляет порядком запуска (важно, чтобы udev успел создать by-path);
даёт логи (
journalctl);работает стабильно и предсказуемо.
Создаём unit:
sudo nano /etc/systemd/system/robot-devices-symlinks.service
Содержимое:
[Unit]
Description=Create stable device symlinks in /dev (by-path)
After=systemd-udevd.service
Wants=systemd-udevd.service
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/robot-dev-symlinks
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
Активируем:
sudo systemctl daemon-reload
sudo systemctl enable --now robot-devices-symlinks.service
Проверяем статус и логи:
sudo systemctl status robot-devices-symlinks.service
journalctl -u robot-devices-symlinks.service -b --no-pager
Реальный конфиг на текущий момент
Вот как выглядит файл /etc/robot-devices.map на моём роботе:
# Serial
serial /dev/serial/by-path/platform-xhci-hcd.1-usb-0:1:1.0-port0 rplidar
serial /dev/serial/by-path/platform-xhci-hcd.0-usb-0:2:1.0-port0 esp32_drive
# Stereo-camera
v4l /dev/v4l/by-path/platform-xhci-hcd.0-usb-0:1:1.0-video-index0 cam_left
v4l /dev/v4l/by-path/platform-xhci-hcd.0-usb-0:1:1.0-video-index1 cam_right
В результате после загрузки робота всегда получаю:
/dev/rplidar -> /dev/ttyUSB0 (или другой, но симлинк стабилен)
/dev/esp32_drive -> /dev/ttyUSB1
/dev/cam_left -> /dev/video0
/dev/cam_right -> /dev/video1
И дальше в ROS2 использую только эти имена:
/dev/rplidar/dev/esp32_drive/dev/cam_left,/dev/cam_right
Итоги
Этот подход решает сразу несколько практических проблем:
стабильные имена устройств в
/dev;одинаковые ESP32/камеры больше не путаются;
конфиги и launch-файлы становятся переносимыми и понятными;
система дружит с “готовыми” ROS-драйверами, которые ждут стандартные пути.
Если вы строите робота и хотите, чтобы «вчера работало — и завтра работало», то привязка по by-path — один из самых надёжных и простых способов.
Комментарии (10)

ptr128
11.01.2026 08:23Лично для меня идентификация только по by-path не кажется удобной, так как при наличии большого количество устройств не очень-то поймёшь, куда какой воткнул.
Зато, для примера, ESP32-С3 замечательно идентифицируются по серийному номеру, что позволяет давать произвольные имена устройствам.
Bus 001 Device 015: ID 303a:1001 Espressif USB JTAG/serial debug unit Device Descriptor: bLength 18 bDescriptorType 1 bcdUSB 2.00 bDeviceClass 239 Miscellaneous Device bDeviceSubClass 2 [unknown] bDeviceProtocol 1 Interface Association bMaxPacketSize0 64 idVendor 0x303a Espressif idProduct 0x1001 USB JTAG/serial debug unit bcdDevice 1.01 iManufacturer 1 Espressif iProduct 2 USB JTAG/serial debug unit iSerial 3 80:65:99:2D:19:64На всякий случай, приведу пример использования. Создаем файл /etc/udev/rules.d/91-esp32.rules с содержимым:
ACTION=="add", SUBSYSTEM=="tty", ATTRS{idVendor}=="303a", ATTRS{idProduct}=="1001", ATTRS{serial}=="80:65:99:2D:19:64", SYMLINK+="gate_control_dev_1"Перезагружаем правила udev
sudo udevadm control --reload-rulesПодключаем ESP32-C3 с серийным номером, указанным выше и видим симлинк /dev/gate_control_dev_1

ptr128
11.01.2026 08:23В случае отсутствия серийного номера, как для CH340, действительно приходится ориентироваться на by-path. Но для этого тоже достаточно udev. Например:
ACTION=="add", SUBSYSTEM=="tty", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="7522", DEVPATH=="*/usb1/1-3/1-3:1.0/*", SYMLINK+="ch340_on_USB3-2" ACTION=="add", SUBSYSTEM=="tty", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="7522", DEVPATH=="*/usb1/1-4/1-4.4/1-4.4.3/1-4.4.3:1.0/*", SYMLINK+="ch340_on_USB3HUB-6" ACTION=="add", SUBSYSTEM=="tty", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="7522", DEVPATH=="*/usb1/1-4/1-4.4/1-4.4.4/1-4.4.4:1.0/*", SYMLINK+="ch340_on_USB3HUB-7"Тут первая строка - для встроенного в мой ноут второго порта USB3.0. Вторая - для шестого порта внешнего семипортового USB3.0 хаба. Третья - для седьмого порта того же хаба.

aeder
11.01.2026 08:23Идентификация по серийному номеру (или, например, MAC-адресу) имеет один существенный недостаток - надо настраивать для каждого экземпляра устройства.
Т.е. если вы делаете девайс с тремя сетевыми платами, серийно делаете, хотя бы по 100 штук в месяц - вас задолбает привязка по МАС-адресу каждой платы в каждом девайсе.
А ещё при ремонте - плату заменил, меняй правила в udev. А меняет - местный персонал, который открутить-прикрутить может, а вот прописать правило в udev - нет.
Это как минимум чрезвычайно неудобно.
--------------------
Можно конечно скрипт написать, типа "вставьте кабель/нажмите/где лампочка зажглась скажите" - который бы позволял более-менее автоматизировать привязку по серийнику. Но всё равно неудобно.

ptr128
11.01.2026 08:23неудобно
Это субъективно и зависит от контекста. При этом я специально указал, как при помощи того же самого udev делать идентификацию не только по serial, но и по devpath. Вы возражаете против свободы выбора?

anoldman25
11.01.2026 08:23Спасибо. Честно говоря мне эта мысль не приходила в голову. У меня сейчас 6 серийных устройств, все время в них путаюсь.

el_mago
11.01.2026 08:23Практический вопрос: если исключить переходники с одинаковыми VID/PID и добавить подключение устройств не в те порты + переезд на другой вычислитель, то насколько такой подход удобнее предварительного программного опроса и последующего изменения путей в конфигурации перед запуском робота?
Зачем вы лидар в нижней части расположили?
Stepan_Burmistrov Автор
11.01.2026 08:23С программным опросом сложность возникла как раз с лидером: он при получении "странных", с его точки зрения пакетов просто переставал работать до перезагрузки. А так, в процессе эксплуатации робота особо ничего переключать-то и не нужно (еще повторюсь: я занимаюсь образовательной робототехникой с школьниками).
По поводу лидара снизу: чтобы видеть низкие препятствия и защитить сам лидер, при этом сохранить полный обзор.
Я у себя в канале рассказываю про этот и другие проекты. Если интересно, добро пожаловать!
vdudouyt
Раз уж весь процесс "привязывания" заключается в создании симлинков, почему нельзя сразу открывать в софте по /dev/*/by-path/* вместо /dev/cam_left и иже с ними (к тому же, как я понимаю, все равно не сильно стандартные) - и не городить лишний конфиг, скрипт и юнит systemd?
Stepan_Burmistrov Автор
Причин тому несколько: читаемость ссылок в коде, удобство использования стандартных пакетов для лидаров в ROS2 (конечно, переопределить параметры запуска, можно, а зачем?)
ky0
Спрашивать у работающих с железом, зачем лишний костыль - это смело :)
Подключать новые USB-устройства после запуска мы, конечно же, не будем.