* Ссылка на библиотеку в конце статьи. В самой статье изложены механизмы, реализованные в библиотеке, со средней детализацией. Реализация для 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 единиц? Тут мне уже нужно собрать тестовую сцену, которую тоже надо отладить, и на которую тоже надо потратить время. Именно для таких итераций идеально бы подошла горячая перезагрузка кода. Конечно, это не панацея, подойдет далеко не для всего, да и после перезагрузки иногда нужно пересоздать часть игрового мира, и это нужно учитывать. Но во многих вещах это может быть полезно и может сэкономить концентрацию внимания и кучу времени.
Это минимальный набор требований, которым должна удовлетворять реализация. Забегая вперед, вкратце опишу то, что было реализовано дополнительно:
До этого момента я был совсем далек от предметной области, поэтому пришлось собирать и усваивать информацию с нуля.
На высоком уровне механизм выглядит так:
Начнем с самого интересного — механизма перезагрузки функций.
Вот 3 более-менее популярных способа подмены функций в (или почти в) рантайме:
strcpy
, и сделать так, чтобы при запуске приложение брало мою версию strcpy
вместо библиотечнойПервые 2 варианта, очевидно, не подходят, поскольку работают только с экспортируемыми функциями, а мы не хотим помечать все функции нашего приложения какими-либо аттрибутами. Поэтому Function hooking — наш вариант!
Если вкратце, то hooking работает так:
/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 можно достать и информацию о зависимостях наших ЕТ от других файлов. Но я отказался от этой реализации по нескольким причинам:
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
в директории приложения и в родительских директориях рекурсивно, и достает оттуда всю нужную информацию о ЕТ.compile_commands.json
.Ctrl+r
), библиотека ждет завершения процессов компиляции и линкует все новые объектники в динамическую библиотеку.dlopen
.Работает это весьма неплохо, особенно когда знаешь, что под капотом, и чего ожидать, хотя бы на высоком уровне.
Лично меня очень удивило отсутствие подобного решения для Linux, неужели никто в этом не заинтересован?
Буду рад любой критике, спасибо!
К сожалению, не доступен сервер mySQL