Главная » Хабрахабр » [Из песочницы] Реализация горячей перезагрузки С++ кода в Linux

[Из песочницы] Реализация горячей перезагрузки С++ кода в Linux

image

В самой статье изложены механизмы, реализованные в библиотеке, со средней детализацией. * Ссылка на библиотеку в конце статьи. Здесь в основном рассматривается реализация для Linux. Реализация для macOS еще не закончена, но она мало чем отличается от реализации для Linux.

Сам я слез с windows несколько лет назад, ни капли не пожалел, и сейчас все программирование происходит либо на Linux (дома), либо на macOS (на работе). Гуляя по гитхабу одним субботним днем, я наткнулся на библиотеку, реализующую обновление c++ кода налету для windows. Проблема лишь в том, что я не нашел ни одной реализации под не-windows (плохо искал?). Немного погуглив, я обнаружил, что подход из библиотеки выше достаточно популярен, и msvc использует ту же технику для функции "Edit and continue" в Visual Studio. На вопрос автору библиотеки выше, будет ли он делать порт под другие платформы, ответ был отрицательный.

Сразу скажу, что меня интересовал только вариант, в котором не пришлось бы менять существующий код проекта (как, например, в случае с RCCPP или cr, где весь потенциально перезагружаемый код должен быть в отдельной динамически загружаемой библиотеке).

"Как так?" — подумал я, и принялся раскуривать фимиам.

Зачем?

Большую часть моего рабочего времени я трачу на написание игровой логики и верстку всякого визуального. Я в основном занимаюсь геймдевом. Мой цикл работы с кодом, как вы, наверное, догадались, это Write -> Compile -> Run -> Repeat. Кроме этого я использую imgui для вспомогательных утилит. Проблема тут в том, что этот цикл приходится повторять достаточно часто. Происходит все довольно быстро (инкрементальная сборка, всякие ccache и т.п.). Например, пишу я новую игровую механику, пусть это будет "Прыжок", годный, управляемый Прыжок:

Написал черновую реализацию на основе импульса, собрал, запустил. 1. Увидел, что случайно прикладываю импульс каждый кадр, а не один раз.

Пофиксил, собрал, запустил, теперь нормально. 2. Но надо бы абсолютное значение импульса побольше взять.

Пофиксил, собрал, запустил, работает. 3. Надо попробовать на основе силы сделать. Но как-то ощущается не так.

Написал черновую реализацию на основе силы, собрал, запустил, работает. 4. Надо бы только мгновенную скорость в момент прыжка менять.
...

Пофиксил, собрал, запустил, работает. 10. Наверное нужно попробовать реализацию на основе изменения gravityScale.
... Но все еще не то.

Отлично, выглядит супер! 20. Теперь выносим все параметры в редактор для геймдиза, тестируем и заливаем.
...

Прыжок готов. 30.

На это обычно уходит не меньше 10 секунд. И на каждой итерации нужно собрать код и в запустившемся приложении добраться до места, где я могу попрыгать. А если мне нужно уметь запрыгивать на блоки высотой N единиц? А если я могу попрыгать только на открытой местности, до которой еще надо добраться? Именно для таких итераций идеально бы подошла горячая перезагрузка кода. Тут мне уже нужно собрать тестовую сцену, которую тоже надо отладить, и на которую тоже надо потратить время. Но во многих вещах это может быть полезно и может сэкономить концентрацию внимания и кучу времени. Конечно, это не панацея, подойдет далеко не для всего, да и после перезагрузки иногда нужно пересоздать часть игрового мира, и это нужно учитывать.

Требования и постановка задачи

  • При изменении кода новая версия всех функций должна подменять собой старые версии этих же функций
  • Это должно работать на Linux и macOS
  • Это не должно требовать изменений в существующем коде приложения
  • В идеале это должна быть библиотека, статически или динамически линкуемая к приложению, без сторонних утилит
  • Желательно, чтобы эта библиотека не очень сильно влияла на напроизводительность приложения
  • Достаточно, если это будет работать с cmake + make/ninja
  • Достаточно, если это будет работать с дебажными сборками (без оптимизаций, без обрезания символов и прочего)

Забегая вперед, вкратце опишу то, что было реализовано дополнительно: Это минимальный набор требований, которым должна удовлетворять реализация.

  • Перенос значений статических переменных в новый код (смотрите раздел "Перенос статических переменных", чтобы узнать, почему это важно)
  • Перезагрузка с учетом зависимостей (поменяли заголовочник -> пересобрали полпроекта все зависимые файлы)
  • Перезагрузка кода из динамических библиотек

Реализация

До этого момента я был совсем далек от предметной области, поэтому пришлось собирать и усваивать информацию с нуля.

На высоком уровне механизм выглядит так:

  • Мониторим файловую систему на предмет изменений в исходниках
  • Когда изменяется исходник, библиотека пересобирает его, используя команду компиляции, которой этот файл уже собирали
  • Все собранные объектники линкуются в динамически загружаемую библиотеку
  • Библиотека загружается в адресное пространство процесса
  • Все функции из библиотеки подменяют собой эти же функции в приложении
  • Значения статических переменных переносятся из приложения в библиотеку

Начнем с самого интересного — механизма перезагрузки функций.

Перезагрузка функций

Вот 3 более-менее популярных способа подмены функций в (или почти в) рантайме:

  • Трюк с LD_PRELOAD — позволяет собрать динамически загружаемую библиотеку с, например, функцией strcpy, и сделать так, чтобы при запуске приложение брало мою версию strcpy вместо библиотечной
  • Изменение PLT и GOT таблиц — позволяет "перегружать" экспортируемые функции
  • Function hooking — позволяет перенаправлять поток выполнения из одной функции в другую

Поэтому Function hooking — наш вариант! Первые 2 варианта, очевидно, не подходят, поскольку работают только с экспортируемыми функциями, а мы не хотим помечать все функции нашего приложения какими-либо аттрибутами.

Если вкратце, то hooking работает так:

  • Находится адрес функции
  • Первые несколько байт функции перезаписываются безусловным переходом в тело другой функции
  • ...
  • Профит!
    В msvc для этого есть 2 флага — /hotpatch и /FUNCTIONPADMIN. Первый в начало каждой функции записывает 2 байта, которые не делают ничего, для последующей их перезаписи "коротким прыжком". Второй позволяет перед телом каждой функции оставить пустое место в виде nop инструкций для "длинного прыжка" в требуемое место, таким образом в 2 прыжка можно перейти из старой функции в новую. Подробнее о том, как это реализовано в windows и msvc, можно почтитать, например, тут.

На самом деле это не такая большая проблема, будем писать прямо поверх старой функции. К сожалению, в clang и gcc нет ничего похожего (по крайней мере под Linux и macOS). Если обычно в многопоточной среде мы ограничиваем доступ к данным одним потоком, пока другой поток их модифицирует, то тут нам нужно ограничить возможность выполнения кода одним потоком, пока другой поток этот код модифицирует. В этом случае мы рискуем попасть в неприятности, если наше приложение многопоточное. Я не придумал, как это сделать, поэтому реализация будет вести себя непредсказуемо в многопоточной среде.

На 32-битной системе нам достаточно 5 байт, чтобы "прыгнуть" в любое место. Тут есть один тонкий момент. Суть в том, что 14 байт в масштабах машинного кода — достаточно много, и если в коде есть какая-нибудь функция-заглушка с пустым телом, она скорее всего будет меньше 14 байт в длину. На 64-битной системе, если мы не хотим портить регистры, понадобится 14 байт. А это значит, что между началом любых двух функций будет не меньше 16 байт, чего нам с головой хватит, чтобы "захукать" их. Я не знаю всей правды, но я провел некоторое время за дизассемблером, пока думал, писал и отлаживал код, и я заметил, что все функции выровнены по 16-байтной границе (debug билд без оптимизаций, не уверен насчет оптимизированного кода). В любом случае, если есть сомнения, достаточно просто объявить пару переменных в начале функции-заглушки, чтобы она стала достаточно большой. Поверхностное гугление привело сюда, тем не менее я точно не знаю, мне просто повезло, или сегодня все компиляторы так делают.

Итак, у нас есть первая крупица — механизм перенаправления функций из старой версии в новую.

Поиск функций в скопмилированной программе

Это можно сделать достаточно просто, используя системные api, если из вашего приложения не вырезаны символы. Теперь нам нужно как-то получить адреса всех (не только экспортированных) функций из нашей программы или произвольной динамической библиотеки. На Linux это api из elf.h и link.h, на macOS — loader.h и nlist.h.

  • Используя dl_iterate_phdr проходимся по всем загруженным библиотекам и, собственно, программе
  • Находим адрес, по которому загружена библиотека
  • Из секции .symtab достаем всю информацию о символах, а именно имя, тип, индекс секции, в которой он лежит, размер, а также вычисляем его "реальный" адрес на основе виртуального адреса и адреса загрузки библиотеки

При загрузке elf файла система не загружает секцию .symtab (поправьте, если неправ), а секция .dynsym нам не подходит, поскольку из нее мы не сможем выудить символы с видимостью STV_INTERNAL и STV_HIDDEN. Здесь есть одна тонкость. Проще говоря, мы не увидим таких функций:

// some_file.cpp
namespace

}

и таких переменных:

// some_file.cpp
void someDefaultFunction()
{ static int someVariable = 0; // <----- ...
}

Так мы ничего не пропустим. Таким образом в 3-м пункте мы работаем не с программой, которую нам дала dl_iterate_phdr, а с файлом, который мы загрузили с диска и разобрали каким-нибудь elf парсером (либо на голом api). На macOS процедура аналогичная, только названия функций из системных api другие.

После этого мы фильтруем все символы и сохраняем только:

  • Функции, которые можно перезагрузить — это символы типа STT_FUNC, расположенные в секции .text, имеющие ненулевой размер. Такой фильтр пропускает только функции, код которых реально содержится в этой программе или библиотеке
  • Статические переменные, значения которых нужно перенести — это символы типа STT_OBJECT, расположенные в секции .bss

Единицы трансляции

Чтобы перезагружать код, нам нужно знать, откуда брать файлы с исходным кодом и как их компилировать.

Чтобы в каждую единицу трансляции (ЕТ) в рамках DWARF попала строка компиляции этой ЕТ, необходимо при компиляции передавать флах -grecord-gcc-switches. В первой реализации я читал эту информацию из секции .debug_info, в которой лежит отладочная информация в формате DWARF. Кроме команды компиляции из DWARF можно достать и информацию о зависимостях наших ЕТ от других файлов. Сам же DWARF я парсил библиотекой libdwarf, которая идет в комплекте с libelf. Но я отказался от этой реализации по нескольким причинам:

  • Библиотеки достаточно увесистые
  • Разбор DWARF приложения, собранного из ~500 ЕТ, с парсингом зависимостей, занимал чуть больше 10 секунд

После недолгих раздумий я переписал логику парсинга DWARF на парсинг compile_commands.json. 10 секунд на старте приложения — слишком много. Таким образом мы получаем всю нужную нам информацию. Этот файл можно сгенерировать, просто добавив set(CMAKE_EXPORT_COMPILE_COMMANDS ON) в свой CMakeLists.txt.

Обработка зависимостей

Парсить файлы руками и искать в них include'ы очень не хотелось, да и кто знает о зависимостях больше, чем сам компилятор? Поскольку мы отказались от DWARF, нужно найти другой вариант, как обрабатывать зависимости между файлами.

Эти файлы используют системы сборки make и ninja для разруливания зависимостей между файлами. В clang и gcc есть ряд опций, которые почти бесплатно генерируют так называемые depfile'ы. Depfile'ы имеют очень простой формат:

CMakeFiles/lib_efsw.dir/libs/efsw/src/efsw/DirectorySnapshot.cpp.o: \ /home/ddovod/_private/_projects/jet/live/libs/efsw/src/efsw/base.hpp \ /home/ddovod/_private/_projects/jet/live/libs/efsw/src/efsw/sophist.h \ /home/ddovod/_private/_projects/jet/live/libs/efsw/include/efsw/efsw.hpp \ /usr/bin/../lib/gcc/x86_64-linux-gnu/7.3.0/../../../../include/c++/7.3.0/string \ /usr/bin/../lib/gcc/x86_64-linux-gnu/7.3.0/../../../../include/x86_64-linux-gnu/c++/7.3.0/bits/c++config.h \ /usr/bin/../lib/gcc/x86_64-linux-gnu/7.3.0/../../../../include/x86_64-linux-gnu/c++/7.3.0/bits/os_defines.h \
...

Итого парсинг compile_commands.json + depfiles для тех же 500 ЕТ занимает чуть больше 1 секунды. Компилятор кладет эти файлы рядом с объектными файлами для каждой ЕТ, нам остается распарсить их и положить в хэшмапу. Для того, чтобы все заработало, нам нужно глобально для всех файлов проекта в опции компиляции добавить флаг -MD.

Эта система сборки генерирует depfile'ы вне зависимости от наличия флага -MD для своих нужд. Здесь есть одна тонкость, связанная с ninja. Поэтому при запуске ninja необходимо передать флаг -d keepdepfile. Но после их генерации она их переводит в свой бинарный формат, а исходные файлы удаляет. Поэтому нужно проверять наличие обеих версий. Также, по неизвестным мне причинам, в случае с make (с опцией -MD) файл имеет название some_file.cpp.d, в то время как с ninja он называется some_file.cpp.o.d.

Перенос статических переменных

Пусть у нас есть такой код (пример весьма синтетический):

// Singleton.hpp
class Singletor
{
public: static Singleton& instance();
}; int veryUsefulFunction(int value); // Singleton.cpp
Singleton& Singletor::instance()
{ static Singleton ins; return ins;
} int veryUsefulFunction(int value)
{ return value * 2;
}

Мы хотим изменить функцию veryUsefulFunction на такую:

int veryUsefulFunction(int value)
{ return value * 3;
}

Как следствие, программа начнет вызывать новые версии обеих функций. При перезагрузке в динамическую библиотеку с новым кодом, кроме veryUsefulFunction, попадет и статическая переменная static Singleton ins;, и метод Singletor::instance. Мы этого, конечно, не хотим. Но статическая ins в этой библиотеке еще не инициализирована, и поэтому при первом обращении к ней будет вызван конструктор класса Singleton. Поэтому реализация переносит значения всех таких переменных, которые обнаружит в собранной динамической библиотеке, из старого кода в эту самую динамическую библиотеку с новым кодом вместе с их guard variables.

Тут есть один тонкий и в общем случае неразрешимый момент.
Пусть у нас есть класс:

class SomeClass
{
public: void calledEachUpdate() { m_someVar1++; }
private: int m_someVar1 = 0;
};

Мы меняем его, добавляя новое поле: Метод calledEachUpdate вызывается 60 раз в секунду.

class SomeClass
{
public: void calledEachUpdate() { m_someVar1++; m_someVar2++; }
private: int m_someVar1 = 0; int m_someVar2 = 0;
};

Аллоцированный экземпляр содержит только переменную m_someVar1, но после перезагрузки метод calledEachUpdate будет пытаться изменить m_someVar2, меняя то, что на самом деле не принадлежит этому экземпляру, что приводит к непредсказуемым последствиям. Если экземпляр этого класса располагается в динамической памяти или на стеке, после перезагрузки кода приложение скорее всего упадет. Библиотека предоставляет события в виде методов делегата onCodePreLoad и onCodePostLoad, которые приложение может обработать. В этом случае логика по переносу состояния перекладывается на программиста, который должен как-то сохранить состояние объекта и удалить сам объект до перезагрузки кода, и создать новый объект после перезагрузки.

Сейчас этот случай "более менее нормально" отработает только для статических переменных, там используется такая логика: Я не знаю как (и можно ли) разрешить эту ситуацию в общем виде, буду думать.

void* oldVarPtr = ...;
void* newVarPtr = ...;
size_t oldVarSize = ...;
size_t newVarSize = ...;
memcpy(newVarPtr, oldVarPtr, std::min(oldVarSize, newVarSize));

Это не очень корректно, но это лучшее, что я придумал.

То же самое относится и к полиморфным типам. В результате код будет вести себя непредсказуемо в случае, если в рантайме меняется набор и расположение (layout) полей в структурах данных.

Собираем все вместе

Как все это работает вместе.

  • Библиотека итерируется по заголовкам всех динамически загруженных в процесс библиотек и, собственно, самой программы, парсит и фильтрует символы.
  • Далее библиотека пытается найти файл compile_commands.json в директории приложения и в родительских директориях рекурсивно, и достает оттуда всю нужную информацию о ЕТ.
  • Зная путь к объектным файлам, библиотека загружает и парсит depfile'ы.
  • После этого вычисляется наиболее общая директория для всех файлов исходного кода программы, и начинается наблюдение за этой директорией рекурсивно.
  • Когда изменяется какой-то файл, библиотека смотрит, если ли он в хэшмапе зависимостей, и если есть, запускает в фоне несколько процессов компиляции измененных файлов и их зависимостей, используя команды компиляции из compile_commands.json.
  • Когда программа просит перезагрузить код (в моем приложении на это назначена комбинация Ctrl+r), библиотека ждет завершения процессов компиляции и линкует все новые объектники в динамическую библиотеку.
  • Затем эта библиотека загружается в адресное пространство процесса функцией dlopen.
  • Из этой библиотеки загружается информация по символам, и все пересечение множества символов из этой библиотеки и уже живущих в процессе символов либо перезагружается (если это функция), либо переносится (если это статическая переменная).

Работает это весьма неплохо, особенно когда знаешь, что под капотом, и чего ожидать, хотя бы на высоком уровне.

Лично меня очень удивило отсутствие подобного решения для Linux, неужели никто в этом не заинтересован?

Буду рад любой критике, спасибо!

Ссылка на реализацию


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

Как провалить внедрение CRM-системы?

Одной моей коллеге очень хотелось иметь iPhone 4S. Тогда это был просто верх понта. Получив премию, она отказалась от отпуска и купила его — белый, приятно увесистый, зависть всей коммерческой службы. Через некоторое время она начала жаловаться, что, мол, не ...

Проверка FreeRDP с помощью анализатора PVS-Studio

FreeRDP – свободная реализация клиента Remote Desktop Protocol (RDP), протокола, реализующего удаленное управление компьютером, разработанного компанией Microsoft. Проект поддерживает множество платформ, среди которых Windows, Linux, macOS и даже iOS с Android. Этот проект выбран первым в рамках цикла статей, посвященных ...