CRISPR для ARM: чиним ДНК робота

Я Лев Ларин, инженер Unibot. Мы развиваем UNIBOT Fleet и подключаем к ней Alfa Robotics SpeedyBot — робота-доставщика с камерами, лидарами и закрытым навигационным ПО. Управление, карты и данные переносим в свою платформу. Это четвёртая история из десяти: исправляем порядок сообщений внутри готовой ARM64-библиотеки. Исходников нет. Кнопка «Собрать проект» тоже не приехала.
Начало серии · ← Часть 3: 25 FPS с видеокамеры по 3G. Модем, извини · Часть 5: Гражданин куст, уступите дорогу →

Робот доехал до Пруда и вернулся на зарядку. Вроде можно расходиться. Но в журнале осталось 25 предупреждений: компонент, обрабатывающий препятствия, не дождался нужного преобразования координат.
Данные о мире уже приехали. Сведения о том, где в этот момент находился сам робот, запаздывали.
Нашёлся конкретный порядок вызовов, который хотелось изменить. Открыть исходник, переставить несколько строк, собрать библиотеку. Обычная работа на вечер.
Только исходника у нас не было.
CRISPR в заголовке — метафора точечного редактирования: меняем небольшой участок программы, сохраняя остальную «ДНК» робота.
Доставщик с закрытым рецептом
У доставщика есть собственное представление о том, как надо ездить по парку. Последнее мы постепенно приводим в соответствие с окружающей действительностью.
На роботе уже работают навигация, локализация и управление железом. Переписывать каждую из этих частей ради собственного интерфейса было бы отдельной многолетней биографией.
Поэтому мы сохраняем полезные штатные компоненты и разбираемся с конкретными дефектами по месту. Про доступ внутрь машины уже рассказывали в истории с JDWP; здесь доступ есть, а вот проекта с кнопкой «Собрать» нет.
Вместо него — готовая ARM64-библиотека для Linux. Файл с машинными инструкциями, который загружает программа локализации. Рецепт не приложен, блюдо перед нами.
Дизассемблер помогает прочитать, какие инструкции выполняются, куда передаётся управление и какие функции вызывают друг друга. Полные исходники из него автоматически не высыпаются. Но для вопроса «что в этом месте происходит раньше, а что позже?» иногда достаточно восстановить небольшой участок.
Практический набор инструментов: Python, pyelftools для структуры ELF, Capstone для инструкций AArch64, Unicorn для исполнения отдельных функций, QEMU для Linux/ROS целиком. Вставку собираем Zig cc с целью aarch64-linux-gnu: это C и небольшой ассемблер, а не переписанная на Python локализация.
Первый проход по библиотеке можно повторить обычными binutils. liblio.so ниже — условное имя исследуемого файла:
file liblio.so
readelf -h -l -Ws liblio.so
aarch64-linux-gnu-objdump -d -C liblio.so > lio.asm
readelf -l нужен не для красоты: виртуальный адрес инструкции и смещение в файле ELF — разные числа. Для подходящего PT_LOAD считаем file_offset = p_offset + (VA - p_vaddr). И только затем читаем четыре байта инструкции. Иначе можно очень убедительно исправить соседний участок файла.
Посылка приехала раньше адреса
Лидар выдаёт облако точек: множество измерений поверхности вокруг робота. Чтобы использовать их вместе с другими данными, нужно понимать, где находился и как был повёрнут корпус в момент этих измерений.
Представьте точку в двух метрах перед роботом. Когда робот повернёт, «перед ним» окажется в другом месте карты. К одной и той же точке нельзя прикладывать произвольную удобную позу из соседнего момента.
В ROS за связи между системами координат отвечает TF. Для нашей истории это справка, которая позволяет перейти от координат одного узла к другому и относится к определённому времени.
На нашей плате Rockchip RK3588 работает Linux/AArch64 и ROS1. Лидар RoboSense Airy Lite публикует /rslidar_points; штатный laserLocation совмещает точки с IMU и выдаёт /cloud_registered. Затем obstacle_cloud_classifier использует преобразования координат для обработки препятствий. Для точки p преобразование имеет вид p_base = R(t) × p_sensor + T(t), где t — время измерения. Подставить последнюю доступную позу вместо позы этого момента — значит незаметно передвинуть предмет вокруг поворачивающегося робота.
Поэтому проверяем вместе метки облака, одометрии и /tf, а не только среднюю частоту топиков:
rostopic echo -n 1 /cloud_registered/header
rostopic hz /cloud_registered
rosbag record -O timing.bag /cloud_registered /tf /tf_static
Это минимальная запись для разбора порядка выходных сообщений. Для повторного запуска самого LIO нужны также его исходные LiDAR/IMU-входы: в наших полных записях они были. Увеличение таймаута потребителя могло бы замаскировать гонку, но порядок вычислений внутри производителя осталось бы прежним.
А теперь порядок работы штатного компонента. Сначала он формировал и публиковал облако. Потом продолжал текущий расчёт положения и публиковал одометрию и соответствующие TF.
Потребитель уже получил посылку, но адресный лист ещё печатался.
Классификатор препятствий ждал преобразование до одной десятой секунды. Иногда нужного TF за это время не появлялось, и в журнал попадало TF sync failed. В сохранённых наблюдениях подходящие преобразования приходили уже после предупреждения.
Потом, при просмотре записи целиком, они были на месте. Это особенно неприятный вид ошибки: задним числом у всех всё есть. Попробуй докажи, что в нужную секунду справка стояла в соседней очереди.
Сначала сопоставили временные метки и предупреждения. Затем проверили машинный код библиотеки: вызов публикации облака действительно стоял раньше текущей публикации позы и TF. Отдельный управляемый опыт с одним и тем же входом показал, что поздний TF меняет выход обработчика. Просто объявить предупреждение шумом уже не получалось.
Кусочек ARM на обычном компьютере
Работать с закрытой библиотекой сильно легче, когда её можно гонять отдельно от тяжёлого робота на дорожке.
Этот приём мы освоили раньше, на планировщике маршрутов. В Unicorn загружали настоящий ARM64-код его функций и исполняли на ПК. Эмулятор выполняет инструкции другого процессора; мы задаём память, входные данные и нужное окружение.
Робот на это время не обязан куда-то ехать. Можно дать функции карту со стеной, поменять стоимость газона, разорвать дорожку, повторить один случай десять раз. В библиотеке работает её собственный поиск пути. Переписанный нами на Python «примерно такой же алгоритм» здесь ничего не доказал бы.
Но часть окружения в таком стенде подставная. Внешние вызовы ROS и C++ обслуживает подготовленная обвязка. Мы проверяем конкретный код с конкретными входами, а не запускаем целого доставщика в виртуальном парке. Время этого исполнения тоже не является скоростью настоящего робота.
У Unicorn мы создаём ARM64-процессор, отображаем сегменты ELF, готовим стек и аргументы по AArch64 ABI. Суть запуска выглядит так — это схема стенда, загрузчик сегментов и обработчики внешних вызовов опущены:
from unicorn import Uc, UC_ARCH_ARM64, UC_MODE_ARM
from unicorn.arm64_const import UC_ARM64_REG_SP, UC_ARM64_REG_X0
uc = Uc(UC_ARCH_ARM64, UC_MODE_ARM)
# map_elf_segments(uc, elf); map_stack_and_heap(uc)
uc.reg_write(UC_ARM64_REG_SP, stack_top)
uc.reg_write(UC_ARM64_REG_X0, native_object_address) # this
uc.emu_start(function_address, return_trap)
stack_top, адрес объекта и точка возврата задаются загрузчиком стенда. В настоящем вызове метода недостаточно положить код в память: ему нужны корректные C+±объекты, аллокатор и ответы на обращения к ROS. Именно для этого у нас есть обвязка, а для сложного полного процесса — отдельная ARM64-VM.
Для разных вопросов получаются разные стенды. Изолированная функция удобна, когда нужно понять её решение. Для проверки настоящего процесса локализации вместе с ROS понадобится уже ARM64-машина с более полным окружением. До неё мы ещё дойдём.
Переставить вызов оказалось слишком просто
Первая идея для нашей гонки выглядела очевидно: перенести формирование облака ниже, после публикации позы.
При разборе библиотеки обнаружились две неприятности.
Между старым и новым местом меняются данные, которыми пользуется формирование точек. Если вызвать функцию позже, можно получить уже другие координаты. Мы собирались поправить расписание почты, а заодно могли незаметно переписать содержимое посылки.
Кроме того, у штатной функции есть несколько ранних выходов. До нового места вызова она на них вообще не дойдёт. Раньше облако публиковалось, после наивной перестановки исчезнет.
Идея «просто передвинуть строку» до живой машины не доехала. Слишком много побочных изменений для ремонта порядка сообщений.
Решение получилось чуть аккуратнее: сформировать облако в прежнем месте, а отправить позже.
Сохранить сейчас, отправить потом
Добавили небольшой фрагмент своего кода, собрали его для ARM64 и встроили в готовую библиотеку. Несколько машинных переходов направляют выполнение через эту вставку и возвращают в штатный код.
В упрощённом виде получилось так:

Порядок обычного шага: облако формируем на прежнем месте, меняем только момент отправки.
Облако формирует прежняя функция в прежней точке расчёта. Вставка переносит готовое сообщение в небольшой временный объект на стеке текущего вызова. После штатной публикации одометрии и TF отправляет сохранённое сообщение.
Координаты, время измерения и систему координат эта вставка не пересчитывает. Общей очереди «на потом», отдельной службы и нового потока у неё нет. Временный объект принадлежит одному текущему шагу работы.
Зачем сообщениям владение
В проверенной сборке нативный объект PointCloud2 занимает 128 байт, а наш Frame содержит эти байты и указатель на publisher. Это размер конкретного C++ ABI, не длина самого облака: массив точек хранится отдельно.
В frame.c перенос начинается с такого кода:
typedef unsigned char Byte;
typedef struct { Byte message[128]; void *publisher; } Frame;
void move_cloud(void *publisher, void *message, Frame *frame) {
Byte *source = message;
for (unsigned i = 0; i < 128; ++i)
frame->message[i] = source[i];
if (*(void **)(source + 16) == source + 32)
*(void **)(frame->message + 16) = frame->message + 32;
frame->publisher = publisher;
for (unsigned i = 0; i < 128; ++i) source[i] = 0;
*(void **)(source + 16) = source + 32;
}
Самая подозрительная строка — поправка указателя source + 32. Это small string optimization в используемой libstdc++: короткий frame_id может лежать внутри самого объекта строки. Простая копия оставила бы указатель в старом, уже уничтоженном сообщении. Для длинной строки данные находятся в отдельной памяти, и адрес сохраняется. После переноса исходный объект приводим к корректному пустому состоянию, чтобы его штатный деструктор не освободил переданную память второй раз.
Это точечная работа с известной раскладкой ABI. Для другой версии ROS, libstdc++ или другого типа сообщения эти смещения нужно заново исследовать; универсального memcpy-рецепта здесь нет.
Сам выпуск короткий:
void flush_frame(Frame *frame) {
if (frame->publisher) {
native_publish(frame->publisher, frame->message);
discard_frame(frame);
}
}
Внутри discard_frame вызывается родной деструктор сообщения, затем publisher обнуляется. Владение сохраняем до возвращения из native_publish: если тот бросит исключение, сообщение ещё есть кому освободить.
Как попасть во вставку и вернуться
У AArch64 инструкция занимает четыре байта. Наш Python-патчер заменяет несколько проверенных B/BL на переходы к добавленному исполняемому сегменту ELF. Вот настоящий код кодирования перехода:
def branch(address, target, link=False):
delta = target - address
if delta % 4 or not -(1 << 27) <= delta < (1 << 27):
raise ValueError('native branch displacement outside AArch64 ABI')
opcode = 0x94000000 if link else 0x14000000
return opcode | ((delta // 4) & 0x3ffffff)
BL сохраняет адрес возврата, B просто передаёт управление. Смещение должно быть кратно четырём и укладываться в диапазон инструкции. Перед заменой сверяем исходную инструкцию и версию библиотеки: смещения стека и расположение полей восстановлены именно для неё.
Обёртка резервирует 160 байт стека; Frame начинается со смещения 16. Перехваченная публикация переносит туда сообщение. Сразу после штатной PublishOdometry, которая выпускает также TF, вызываем flush_frame. На обычном раннем выходе без odometry выпускаем прежнее сообщение перед возвратом, сохраняя исходное поведение этой ветки.
Сообщение при этом живёт в памяти. Его нельзя случайно освободить раньше отправки или уничтожить дважды. А ещё программа может завершить шаг раньше обычного или выйти по исключению.
Поэтому проверяли и неприятные ветки: пустой результат, ранний выход, ошибку внутри родного кода, ошибку публикации. При штатном раннем выходе сохраняется прежнее выходное сообщение. При исключении нужно освободить память и передать исходную ошибку дальше. Успешную позу вместо отсутствующей вставка не выдумывает.
Одна из первых версий провалила проверку выхода по исключению: не хватало служебного описания для корректного продолжения такого выхода. Нормальный путь уже выглядел рабочим. Этот дефект нашли и исправили до установки.
Речь о DWARF CFI и LSDA: таблицах, по которым C++ раскручивает стек. В ассемблере вставки есть .cfi_personality, .cfi_lsda и cleanup, вызывающий discard_frame, затем _Unwind_Resume. В первой версии таблица покрывала вызов, но не весь участок продолжения исключения; fixture обнаружил аварийное завершение. Добавили недостающий диапазон без обработчика. Проверка только «робот проехал прямо» такую ошибку не ловит.
Сборка payload у нас использует такие параметры; в строке оставлены флаги, существенные для автономного кода и исключений:
zig cc -target aarch64-linux-gnu -O2 -ffreestanding \
-fno-stack-protector -fno-builtin -fPIC -fvisibility=hidden \
-fno-lto -fasynchronous-unwind-tables -nostdlib -static \
-Wl,--no-gc-sections -Wl,-T,payload.ld -Wl,--eh-frame-hdr \
-Wl,-e,run_entry frame.c abi.S -o payload.elf
payload.ld связывает обращения с родными функциями конкретной библиотеки. -nostdlib не отменяет C+±исключения окружающей программы: unwind-описания вставки всё равно должны с ними согласовываться.
Вот за что полезно любить скучные проверки памяти: они портят праздник на компьютере, пока робот ещё ничего об этом не знает.
Повторяем запись, а не погоду
Для самой TF-вставки отдельно собрали ARM64-программу, которая проверяла её работу со штатными соглашениями вызова и механизмом исключений C++. В ARM64-среде прошли десять случаев: обычный ход, ранний выход, пустой результат и ошибки, с двумя вариантами размещения строк в памяти.
Это проверка вставки и её обвязки. Она ещё не говорит, что вся программа локализации правильно обработает данные сенсоров.
Следующий уровень — полный нативный replay процесса LIO в ARM64-виртуальной машине под QEMU. LIO оценивает движение по лидару и инерциальным датчикам. Здесь запускается настоящая программа с настоящей библиотекой и ROS, а на вход подаётся сохранённая запись LiDAR/IMU.
Сначала исходная библиотека, затем исправленная. Вход один и тот же. Уличного дубля с просьбой «повернись точно так же, как вчера» не требуется.
В одном парном прогоне обе версии выдали по 69 облаков. Сравнивали количество сообщений, их структуру и размеры, позы и TF. Дополнительный прогон с 29 парами облаков использовали для сравнения самих координат.
Полного побайтового совпадения всех выходных данных между двумя запусками не было. В отдельном сравнении координат максимальное расхождение оказалось меньше одной десятой миллиметра. Это результат сравнения запусков, а не измерение точности робота. Сам патч готовые точки не пересчитывает — это проверялось отдельно.
Виртуальная машина охватывает больше настоящего окружения, чем изолированная функция в Unicorn. Но колёс у неё по-прежнему нет, а часы воспроизведения искусственные. По её времени нельзя обещать задержку на реальном роботе. Зато можно проверить, что новая библиотека действительно работает внутри родного процесса на записанном входе.
Площадка, Пруд, зарядка
После локальных проверок библиотеку установили на робота с отдельной резервной копией. Перезапустили только нужный компонент локализации с текущей позой и картой. Проверили, что работающий процесс действительно загрузил новую библиотеку: положить файл рядом недостаточно.
Дальше обычная Fleet-панель и три последовательные команды: Площадка, Пруд, возврат на док. Без джойстика, ручного вывода и повторных попыток. Карты, скорости и защиты ради этого проезда не меняли.
Команда | Время до успешного завершения |
|---|---|
Площадка | 25,25 с |
Пруд | 37,22 с |
Возврат на док со стыковкой | 79,34 с |
Все три команды завершились успешно. Стыковку подтвердил не только значок в браузере: шасси сообщило о контакте зарядки, штатный процесс стыковки — об успехе.

Fleet после возвращения на док. Контакт зарядки подтверждён логами шасси.
За полное проверенное окно поездки в журналах не было TF sync failed и вызовов реплики «Проезд перекрыт, пожалуйста, уступите дорогу». Последнее проверяли по журналам озвучки, без акустической записи. Штатный контроль блокировки оставался включён.
Это результат установленного комплекта: в нём уже были другие исправления обработки препятствий. Успешный маршрут и отсутствие ложной реплики нельзя целиком приписать одной вставке. Другие диагностические сообщения в логах оставались; один ночной проезд также не проверяет все будущие препятствия и условия.
Но у конкретного ремонта теперь есть вся цепочка: наблюдение на роботе, найденный порядок вызовов, неудачная простая гипотеза, небольшая вставка, повтор записанного входа и проверка установленной версии.
Робот вернулся на зарядку. А у нас вместо подозрительного места в закрытой библиотеке появился участок кода, поведение которого можно проверить и повторить.
Все 10 историй SpeedyBot
Робот доставщик дороже автомобиля, мокрая трава и открытый JDWP
CRISPR для ARM: чиним ДНК робота — вы здесь.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.