Реализация горячей перезагрузки С++ кода в Linux

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

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

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

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

Зачем?

Я в основном занимаюсь геймдевом. Большую часть моего рабочего времени я трачу на написание игровой логики и верстку всякого визуального. Кроме этого я использую imgui для вспомогательных утилит. Мой цикл работы с кодом, как вы, наверное, догадались, это Write -> Compile -> Run -> Repeat. Происходит все довольно быстро (инкрементальная сборка, всякие 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 — позволяет перенаправлять поток выполнения из одной функции в другую

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

  Ротару отказалась выступать в России даже за двойной гонорар

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

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

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

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

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

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

Теперь нам нужно как-то получить адреса всех (не только экспортированных) функций из нашей программы или произвольной динамической библиотеки. Это можно сделать достаточно просто, используя системные 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
{
int someUsefulFunction(int value) // <——
{
return value * 2;
}
}

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

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

}

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

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

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

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

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

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

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

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

  Самые прикольные СМС поздравления на День полиции

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

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

В clang и gcc есть ряд опций, которые почти бесплатно генерируют так называемые depfile’ы. Эти файлы используют системы сборки make и ninja для разруливания зависимостей между файлами. 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.

Здесь есть одна тонкость, связанная с ninja. Эта система сборки генерирует depfile’ы вне зависимости от наличия флага -MD для своих нужд. Но после их генерации она их переводит в свой бинарный формат, а исходные файлы удаляет. Поэтому при запуске 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, неужели никто в этом не заинтересован?

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

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

ОСТАВЬТЕ ОТВЕТ

Please enter your comment!
Please enter your name here