В любой системе, в частности, в роботе где есть 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 — один из самых надёжных и простых способов.
