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

image


* Ссылка на библиотеку в конце статьи. В самой статье изложены механизмы, реализованные в библиотеке, со средней детализацией. Реализация для 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, неужели никто в этом не заинтересован?


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


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

Share post

Similar posts

Comments 29

    +1
    Еще есть библиотека dynamix для изменения поведения «на ходу». .На с++ russia 2018 был хороший доклад от автора. Он привел несколько хороших «наглядных» примеров из мира геймдева.
      0
      Интересно, посмотрим, спасибо. Бросается в глаза то, что библиотека в некотором роде диктует архитектуру приложения. Но сам подход выглядит необычно для c++
      0
      1. Останавливаем целевой процесс через ptrace
      2. Подливаем обновленный объектник по-живому в память (при необходимости выделяем ещё или используем пустые хвосты в конце последних страниц секций)
      3. Ручками разрешаем символы как это делает динамический загрузчик в подлитом объектнике превращая его в рабочий код
      4. Перекидываем на него GOT/PLT
      5. Восстанавливаем работу процесса
      6. Все это делается внешним относительно пациента приложением

      Ну это так. Очень мазками по верхам. Когда-то давно приходилось автоматически патчить по-живому. После определенных трахов вполне работает.
        0
        Это тоже вариант. Может вы еще знаете, как остановить все потоки, кроме текущего, на время патчинга, хотя бы на linux? Чтобы не прибегать к помощи внешней программы
          0

          Отправить им SIGSTOP?

            0

            В смысле через pthread_kill? Вы пробовали провернуть такое? Из того, что удалось нагуглить, это не сработает, но надо пробовать

              0

              Ну типа
              for pid in thread_list
              kill(pid, SIGSTOP)
              И еще завернуть это в цикл, пока что-то было остановлено

                0
                Думаю нет.
                kill — send signal to a process
                  0

                  Ну я не уверен, насколько мой вариант хорош, но суть ваших претензий не совсем понимаю. В linux потоки — нечто, не слишком отличное от процесса, и запуск потока это вроде как clone(CLONE_VM|CLONE_THREAD).

                    0
                    man7.org/linux/man-pages/man3/pthread_kill.3.html
                    Signal dispositions are process-wide: if a signal handler is
                    installed, the handler will be invoked in the thread thread, but if
                    the disposition of the signal is «stop», «continue», or «terminate»,
                    this action will affect the whole process.


                    Поэтому и сомневаюсь
                      0

                      А, ну тогда вообще круто — один kill, и все потоки остановлены.

                        0
                        Задача в том, чтобы остановить все потоки, кроме того, который патчит код.
                        Поэтому остановка всего процесса со всеми потоками не подходит
                          0
                          1. В библиотеке — если вы таки на этом так настаиваете — создаете дочерний процесс
                          2. Выносите в него всю логику отслеживания изменений объектников
                          3. В нем через ptrace(2) останавливаете родительский процесс и патчите как вам хочется
                            0
                            Видимо это единственный вариант, спасибо
        0

        Я мимокрокодил, но вроде бы нужные дырки предусмотрены:


        https://gcc.gnu.org/onlinedocs/gcc/Instrumentation-Options.html


        В частности, интуиция подсказывает, что можно применить -finstrument-functions.

          +1
          Скорее -fpatchable-function-entry=N[,M]

          Generate N NOPs right at the beginning of each function, with the function entry point before the Mth NOP. If M is omitted, it defaults to 0 so the function entry points to the address just at the first NOP. The NOP instructions reserve extra space which can be used to patch in any desired instrumentation at run time, provided that the code segment is writable. The amount of space is controllable indirectly via the number of NOPs; the NOP instruction used corresponds to the instruction emitted by the internal GCC back-end interface gen_nop. This behavior is target-specific and may also depend on the architecture variant and/or other compilation options.

          Собственно то, чего не хватало автору на сколько я понимаю
            0

            А это вообще звучит, как то, чего автор не нашёл — gj:)

              0
              А ведь правда, спасибо за наводку! Жалко в clang этот флаг еще не реализовали
            –2
            У меня мозг завис на заголовке) Если я правильно понял ситуацию, то речь о подмене нативного или управляемого кода, С++ там уже нет ни капли. На мой взгляд, заголовок не совсем корректен.
              0
              Не совсем вас понял. В статье идет речь о том, как без перезапуска приложения обновить в нем работающий машинный код, исходником которого является код на c или c++. Формально в машинный код можно скомпилировать не только код на c или c++, но в описанном подходе используется инструментарий для сборки кода именно на этих двух языках. Или я неправильно вас понял?
                –3
                В статье идет речь о том, как без перезапуска приложения обновить в нем работающий машинный код

                Во, все-таки обновляется машинный код, а не С++, а по заголовку кажется не так…
                Ладно, это я уже придираюсь, наверное. Статья весьма интересная, плюсанул )
                  0
                  А что там такое, что специфично именно для С++? Ну разве что манглинг. Без смены типов параметров заменится любой язык.
                    0
                    Хорошо, если вам и правда это принципиально.
                    Библиотека, которая реализует эту функциональность, написана на c++, пользовательское приложение, к которому линкуется библиотека, должно инстанциировать экземаляр c++ класса, вызывать методы этого c++ класса, возможно реализовать обработку коллбеков от библиотеки в виде наследования от класса-делегата, который тоже является c++ классом. Конечно, при большом желании это можно делать и из не-c++ кода, и перезагружать не-c++ код, но конкретно в этой статье речь идет о c++.
                    Предлагаю закрыть тему с названием статьи.
                      0
                      Ну то есть часть приложения на С++, поэтому рантайм должен быть совместимым с С++. Но Си явно подходит.

                      Чтобы два раза не вставать — почему вы сделали все в одном приложении, а не два разных процесса? Вроде с двумя процессами часть проблем уходит.
                        0
                        Да, си без проблем подойдет.

                        По поводу процессов — если честно, просто не подумал об этом. Только на днях вспомнил, что автор библиотеки под windows так и делает, и задумался о том, что не спроста. В комментарии выше человек посоветовал порождать процесс из приложения, и в нем делать всю грязную работу, видимо так и придется сделать.
                          +1
                          Тут плюс в том, что можно будет подменять те вещи, которые используются вашей библиотекой. В играх это не важно, а в системных вещах полезно.

                          Ну и в итоге можно будет в отлаживаемом приложении ограничиться (при минимальной функциональности) сишными вызовами, а это уже огромная куча языков.
                          0
                          При большом желании это все можно завернуть в c-api, делов на пару часов. Если вам это будет интересно, пожалуйста заведите issue на гитхабе, сделаем
                            0
                            Мне интересно под FreeRTOS, а это значит — делать самому. :-) Но свой загрузчик у нас уже есть.
                  0

                  Напомнило gdb code injections, ещё из полезных фитч есть запись трассе и шаг назад, вроде бы это называется back in time debagger. Помню ещё давно школьник писал статью про модификацию того же gdb где программа форкалась. Из интересного но врядли имеющего отношение к делу есть обратное исполнение, вроде бы был даже коммерческий дебагер с такой возможность, заранее извиняюсь, в плане технологий я больше каталог. Точно помню что это было у меня в заметках года два три назад, пороюсь отпишусь. Конечно мало отношения к тематике стать, но вдруг кого заинтересует

                  Only users with full accounts can post comments. Log in, please.