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

в 18:51, , рубрики: c++, c++11, ld, linux, LLD, MacOS, Разработка под Linux, Разработка под MacOS

Реализация горячей перезагрузки С++ кода в Linux и macOS: копаем глубже - 1
*Ссылка на библиотеку и демо видео в конце статьи. Для понимания того, что происходит, и кто все эти люди, рекомендую прочитать предыдущую статью.

В прошлой статье мы ознакомились с подходом, позволяющим осуществлять "горячую" перезагрузку c++ кода. "Код" в данном случае — это функции, данные и их согласованная работа друг с другом. С функциями особых проблем нет, перенаправляем поток выполнения из старой функции в новую, и все работает. Проблема возникает с данными (статическими и глобальными переменными), а именно со стратегией их синхронизации в старом и новом коде. В первой реализации эта стратегия была очень топорной: просто копируем значения всех статических переменных из старого кода в новый, чтобы новый код, ссылаясь на новые переменные, работал со значениями из старого кода. Конечно это некорректно, и сегодня мы попытаемся исправить этот изъян, попутно решив ряд небольших, но интересных задач.
В статье опущены детали, касающиеся механической работы, например чтение символов и релокаций из elf и mach-o файлов. Упор делается на тонких моментах, с которыми я столкнулся в процессе реализации, и которые могут быть полезны кому-то, кто, как и я недавно, ищет ответы.

Суть

Давайте представим, что у нас есть класс (примеры синтетические, прошу не искать в них смысла, важен только код):

// Entity.hpp
class Entity
{
public:
    Entity(const std::string& description);
    ~Entity();
    void printDescription();

    static int getLivingEntitiesCount();
private:
    static int m_livingEntitiesCount;
    std::string m_description;
};

// Entity.cpp
int Entity::m_livingEntitiesCount = 0;

Entity::Entity(const std::string& description)
    : m_description(description)
{
    m_livingEntitiesCount++;
}

Entity::~Entity()
{
    m_livingEntitiesCount--;
}

int Entity::getLivingEntitiesCount()
{
    return m_livingEntitiesCount;
}

void Entity::printDesctiption()
{
    std::cout << m_description << std::endl;
}

Ничего особенного, кроме статической переменной. Теперь представим, что мы хотим изменить метод printDescription() на такой:

void Entity::printDescription()
{
    std::cout << "DESCRIPTION: " << m_description << std::endl;
}

Что произойдет после перезагрузки кода? В библиотеку с новым кодом, кроме методов класса Entity, попадет и статическая переменная m_livingEntitiesCount. Ничего страшного не случится, если мы просто скопируем значение этой переменной из старого кода в новый, и продолжим пользоваться новой переменной, забыв о старой, ведь все методы, которые используют эту переменную напрямую, находятся в библиотеке с новым кодом.
C++ очень гибок и богат. И пусть элегантность решения некоторых задач на c++ граничит с дурно пахнущим кодом, я люблю этот язык. Например, представьте, что в вашем проекте не используется rtti. В то же время вам нужно иметь реализацию класса Any со сколь-нибудь типобезопасным интерфейсом:

class Any
{
public:
    template <typename T>
    explicit Any(T&& value) { ... }

    template <typename T>
    bool is() const { ... }

    template <typename T>
    T& as() { ... }
};

Не будем вдаваться в детали реализации этого класса. Нам важно лишь то, что для реализации нам нужен какой-то механизм для однозначного отображения типа (compile-time сущность) в значение переменной, например uint64_t (runtime сущность), то есть "пронумеровать" типы. При использовании rtti нам доступны такие вещи, как type_info и, что больше нам подходит, type_index. Но у нас нет rtti. В этом случае достаточно распространенным хаком (или элегантным решением?) является такая функция:

template <typename T>
uint64_t typeId()
{
    static char someVar;
    return reinterpret_cast<uint64_t>(&someVar);
}

Тогда реализация класса Any будет выглядеть как-то так:

class Any
{
public:
    template <typename T>
    explicit Any(T&& value) 
        : m_typeId(typeId<std::decay<T>::type>())
        // copy or move value somewhere
    {}

    template <typename T>
    bool is() const { return m_typeId == typeId<std::decay<T>::type>(); }

    template <typename T>
    T& as() { ... }

private:
    uint64_t m_typeId = 0;
};

Для каждого типа функция будет инстанцироваться ровно 1 раз, соответственно в каждой версии функции будет своя статическая переменная, очевидно со своим уникальным адресом. Что же произойдет, когда мы перезагрузим код, использующий эту функцию? Вызовы старой версии функции будут перенаправляться в новую. В новой будет лежать своя статическая переменная, уже проинициализированная (мы скопировали значение и guard variable). Но нас не интересует значение, мы используем только адрес. И адрес у новой переменной будет другой. Таким образом данные стали несогласованными: в уже созданных экземплярах класса Any будет храниться адрес старой статической переменной, а метод is() будет сравнивать его с адресом новой, и "эта Any уже не будет прежней Any" ©.

План

Чтобы решить эту проблему, нужно что-то более умное, чем простое копирование. Потратив пару вечеров на гугление, чтение документации, исходников и системных api, в голове выстроился следующий план:

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

В этом случае не останется ссылок на новые данные, все приложение продолжит работать со старыми версиями переменных с точностью до адреса. Это должно сработать. Это не может не сработать.

Релокации

Когда компилятор генерирует машинный код, он в каждое место, в котором происходит либо вызов функции, либо загрузка адреса переменной, вставляет несколько байт, достаточных для записи в это место реального адреса переменной или функции, а также генерирует релокацию. Он не может сразу записать реальный адрес, поскольку на этом этапе ему этот адрес неизвестен. Функции и переменные после линковки могут оказаться в разных секциях, в разных местах секций, в конце концов секции могут быть загружены по разным адресам во время выполнения.
Релокация содержит информацию:

  • По какому адресу нужно записать адрес функции или переменной
  • Адрес какой функции или переменной нужно записать
  • Формулу, по которой этот адрес должен быть посчитан
  • Сколько байт зарезервировано под этот адрес

В разных ОС релокации представлены по-разному, но в итоге они все работают по одному принципу. Например, в elf (Linux) релокации расположены в специальных секциях .rela (в 32-битной версии это .rel), которые ссылаются на секцию с адресом, который нужно исправить (например, .rela.text — секция, в которой находятся релокации, применяемые к секции .text), а каждая запись хранит информацию о символе, адрес которого нужно вставить в место релокации. В mach-o (macOS) все немного наоборот, здесь нет отдельной секции для релокаций, вместо этого каждая секция содержит указатель на таблицу релокаций, которые должны быть применены к этой секции, и в каждой записи этой таблицы есть ссылка на релоцируемый символ.
Например, для такого кода (с опцией -fPIC):

int globalVariable = 10;
int veryUsefulFunction()
{
    static int functionLocalVariable = 0;
    functionLocalVariable++;
    return globalVariable + functionLocalVariable;
}

компилятор создаст такую секцию с релокациями на Linux:

Relocation section '.rela.text' at offset 0x1a0 contains 4 entries:
    Offset             Info             Type               Symbol's Value  Symbol's Name + Addend
0000000000000007  0000000600000009 R_X86_64_GOTPCREL      0000000000000000 globalVariable - 4
000000000000000d  0000000400000002 R_X86_64_PC32          0000000000000000 .bss - 4
0000000000000016  0000000400000002 R_X86_64_PC32          0000000000000000 .bss - 4
000000000000001e  0000000400000002 R_X86_64_PC32          0000000000000000 .bss - 4

и такую таблицу релокаций на macOS:

RELOCATION RECORDS FOR [__text]:
000000000000001b X86_64_RELOC_SIGNED __ZZ18veryUsefulFunctionvE21functionLocalVariable
0000000000000015 X86_64_RELOC_SIGNED _globalVariable
000000000000000f X86_64_RELOC_SIGNED __ZZ18veryUsefulFunctionvE21functionLocalVariable
0000000000000006 X86_64_RELOC_SIGNED __ZZ18veryUsefulFunctionvE21functionLocalVariable

А вот так выглядит функция veryUsefulFunction() (в Linux):

0000000000000000 <_Z18veryUsefulFunctionv>:
   0:   55                      push   rbp
   1:   48 89 e5                mov    rbp,rsp
   4:   48 8b 05 00 00 00 00    mov    rax,QWORD PTR [rip+0x0]
   b:   8b 0d 00 00 00 00       mov    ecx,DWORD PTR [rip+0x0]
  11:   83 c1 01                add    ecx,0x1
  14:   89 0d 00 00 00 00       mov    DWORD PTR [rip+0x0],ecx
  1a:   8b 08                   mov    ecx,DWORD PTR [rax]
  1c:   03 0d 00 00 00 00       add    ecx,DWORD PTR [rip+0x0]
  22:   89 c8                   mov    eax,ecx
  24:   5d                      pop    rbp
  25:   c3                      ret    

и так после линковки объектника в динамическую библиотеку:

00000000000010e0 <_Z18veryUsefulFunctionv>:
    10e0:   55                      push   rbp
    10e1:   48 89 e5                mov    rbp,rsp
    10e4:   48 8b 05 05 21 00 00    mov    rax,QWORD PTR [rip+0x2105]
    10eb:   8b 0d 13 2f 00 00       mov    ecx,DWORD PTR [rip+0x2f13]
    10f1:   83 c1 01                add    ecx,0x1
    10f4:   89 0d 0a 2f 00 00       mov    DWORD PTR [rip+0x2f0a],ecx
    10fa:   8b 08                   mov    ecx,DWORD PTR [rax]
    10fc:   03 0d 02 2f 00 00       add    ecx,DWORD PTR [rip+0x2f02]
    1102:   89 c8                   mov    eax,ecx
    1104:   5d                      pop    rbp
    1105:   c3                      ret    

В ней 4 места, в которых зарезервировано по 4 байта для адреса реальных переменных.

На разных системах набор возможных релокаций свой. В Linux на x86-64 целых 40 типов релокаций. На macOS на x86-64 их всего 9. Все типы релокаций условно можно поделить на 2 группы:

  1. Link-time relocations — релокации, применяемые в процессе линковки объектных файлов в исполняемый файл или динамическую библиотеку
  2. Load-time relocations — релокации, применяемые в момент загрузки динамической библиотеки в память процесса

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

Есть тонкий момент, связанный с macOS и его динамическим линковщиком. В macOS реализован так называемый механизм двухуровнего пространства имен (two-level namespace). Если грубо, то при загрузке динамической библиотеки линковщик в первую очередь будет искать символы в этой библиотеке, и если не найдет, пойдет искать в других. Это сделано в целях производительности, чтобы релокации разрешались быстро, что, в общем-то, логично. Но это ломает наш флоу касательно глобальных переменных. К счастью, в ld на macOS есть специальный флаг — -flat_namespace, и если собрать библиотеку с этим флагом, то алгоритм поиска символов будет идентичен таковому в Linux.

В первую же группу попадают релокации статических переменных — именно то, что нам нужно. Единственная проблема в том, что эти релокации отсутствуют в собранной библиотеке, поскольку они уже разрешены линковщиком. Поэтому читать их будем из объектных файлов, из которых была собрана библиотека.
На возможные типы релокаций также накладывает ограничение то, является ли собранный код position-dependent или нет. Поскольку мы собираем наш код в режиме PIC (position-independent code), то и релокации используются только относительные. Итого интересующие нас релокации — это:

  • Релокации из секции .rela.text в Linux и релокации, на которые ссылается секция __text в macOS, и
  • В которых используются символы из секций .data и .bss в Linux и __data, __bss и __common в macOS, и
  • Релокации имеют тип R_X86_64_PC32 и R_X86_64_PC64 в Linux и X86_64_RELOC_SIGNED, X86_64_RELOC_SIGNED_1, X86_64_RELOC_SIGNED_2 и X86_64_RELOC_SIGNED_4 в macOS

Тонкий момент, связанный с секцией __common. В Linux также есть аналогичная секция *COM*. В эту секцию могут попасть глобальные переменные. Но, пока я тестировал и компилировал кучу фрагментов кода, на Linux релокации символов из *COM* секции всегда были динамическими, как у обычных глобальных переменных. В то же время в macOS такие символы иногда релоцировались во время линковки, если функция и символ находятся в одном файле. Поэтому на macOS имеет смысл учитывать и эту секцию при чтении символов и релокаций.

Отлично, теперь у нас есть набор всех нужных нам релокаций, что же с ними делать? Логика тут простая. Когда линковщик линкует библиотеку, он по адресу релокации записывает адрес символа, вычисленный по определенной формуле. Для наших релокаций на обеих платформах эта формула содержит адрес символа в качестве слагаемого. Таким образом вычисленный адрес, уже записанный в тело функций, имеет вид:

resultAddr = newVarAddr + addend - relocAddr

В то же время мы знаем адреса обеих версий переменных — старых, уже живущих в приложении, и новых. Нам осталось изменить его по формуле:

resultAddr = resultAddr - newVarAddr + oldVarAddr

и записать его по адресу релокации. После этого все функции в новом коде будут использовать уже существующие версии переменных, а новые переменные просто будут лежать и ничего не делать. То, что надо! Но есть один тонкий момент.

Загрузка библиотеки с новым кодом

Когда система загружает динамическую библиотеку в память процесса, она вольна разместить ее в любое место виртуального адресного пространства. У меня на Ubuntu 18.04 приложение загружается по адресу 0x00400000, а наши динамические библиотеки — сразу после ld-2.27.so по адресам в районе 0x7fd3829bd000. Расстояние между адресами загрузки программы и библиотеки сильно больше числа, которое бы влезло в знаковый 32-битный integer. А в link-time релокациях резервируется только 4 байта для адресов целевых символов.

Покурив документацию к компиляторам и линковщикам, я решил попробовать опцию -mcmodel=large. Она заставляет компилятор генерировать код без каких-либо предположений о расстоянии между символами, тем самым все адреса подразумеваются 64-битными. Но эта опция не дружит с PIC, как будто -mcmodel=large нельзя использовать вместе с -fPIC, по крайней мере на macOS. Я так и не понял, в чем проблема, возможно на macOS нет подходящих релокаций для такой ситуации.

В библиотеке под windows эта проблема решается так. Руками выделяется кусок виртуальной памяти недалеко от места загрузки приложения, достаточный, чтобы разместить нужные секции библиотеки. Затем в него руками грузятся секции, выставляются нужные права страницам памяти с соответствующими секциями, руками разруливаются все релокации, и производится патчинг всего остального. Я ленив. Мне очень не хотелось делать всю эту работу с load-time релокациями, особенно на Linux. Да и зачем делать то, что уже умеет делать динамический линковщик? Ведь люди, которые его писали, знают гораздо больше, чем я.

К счастью, в документации нашлись нужные опции, позволяющие указать, куда следует загрузить нашу динамическую библиотеку:

  • Apple ld: -image_base 0xADDRESS
  • LLVM lld: --image-base=0xADDRESS
  • GNU ld: -Ttext-segment=0xADDRESS

Эти опции нужно передавать линковщику в момент линковки динамической библиотеки. Тут есть 2 сложности.
Первая связана с GNU ld. Для того, чтобы эти опции сработали, нужно, чтобы:

  • В момент загрузки библиотеки область, в которую мы хотим ее загрузить, была свободна
  • Адрес, указываемый в опции, должен быть кратен размеру страницы (в x86-64 Linux и macOS это 0x1000)
  • По крайней мере в Linux, адрес, указываемый в опции, должен быть кратен выравниванию PT_LOAD сегмента

То есть если линковщик выставил выравнивание в 0x10000000, то эту библиотеку не получится загрузить по адресу 0x10001000, даже с учетом того, что адрес выровнен по размеру страницы. Если одно из этих условий не выполнится, библиотека загрузится "как обычно". У меня в системе GNU ld 2.30, и, в отличие от LLVM lld, он по умолчанию выставляет выравнивание сегмента PT_LOAD в 0x20000, что сильно выбивается из общей картины. Чтобы обойти это, нужно кроме опции -Ttext-segment=... указать -z max-page-size=0x1000. Я потратил день, пока не понял, почему библиотека не грузится туда, куда надо.

Вторая сложность — адрес загрузки должен быть известен на этапе линковки библиотеки. Это не очень сложно организовать. В Linux достаточно распарсить псевдо-файл /proc/<pid>/maps, найти ближайший к программе незанятый кусок, в который влезет библиотека, и адрес начала этого куска использовать при линковке. Размер будущей библиотеки можно примерно прикинуть, посмотрев на размеры объектных файлов, либо распарсив их и посчитав размеры всех секций. В конце концов нам нужно не точное число, а примерный размер с запасом.

В macOS нет /proc/*, вместо этого предлагается воспользоваться утилитой vmmap. Вывод команды vmmap -interleaved <pid> содержит ту же информацию, что и proc/<pid>/maps. Но тут возникает другая сложность. Если в приложении породить дочерний процесс, который выполнит эту команду, и в качестве <pid> будет указан идентификатор текущего процесса, то программа намертво повиснет. Насколько я понял, vmmap останавливает процесс, чтобы прочитать его маппинги памяти, и, видимо, если это вызывающий процесс, то что-то идет не так. На этот случай нужно указывать дополнительный флаг -forkCorpse, чтобы vmmap создал пустой дочерний процесс от нашего процесса, снял с него маппинг и убил его, тем самым не прерывая программу.

В общем-то это все, что нам нужно знать.

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

С этими модификациями итоговый алгоритм перезагрузки кода выглядит так:

  1. Компилируем новый код в объектные файлы
  2. По объектным файлам прикидываем размер будущей библиотеки
  3. Читаем из объектных файлов релокации
  4. Ищем свободный кусок виртуальной памяти рядом с приложением
  5. Собираем динамическую библиотеку с нужными опциями, грузим через dlopen
  6. Патчим код в соответствие с link-time релокациями
  7. Патчим функции
  8. Копируем статические переменные, которые не участвовали в шаге 6

В шаг 8 попадают только guard variables статических переменных, поэтому их смело можно копировать (тем самым сохраняя "инициализированность" самих статических переменных).

Заключение

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

Для полноты картины в реализации не хватает еще 3 увесистых кусков:

  1. Сейчас библиотека с новым кодом грузится в память рядом с программой, хотя в нее может попасть код из другой динамической библиотеки, которая загрузилась далеко. Для фикса необходимо отслеживать принадлежность единиц трансляции к тем или иным библиотекам и программе, и дробить библиотеку с новым кодом при необходимости.
  2. Перезагрузка кода в многопоточном приложении все еще ненадежна (с уверенностью можно перезагружать только код, выполняющийся в том же потоке, в котором находится runloop библиотеки). Для фикса необходимо часть реализации вынести в отдельную программу, и эта программа перед патчингом должна останавливать процесс со всеми потоками, производить патчинг, и возвращать его к работе. Я не знаю, как сделать это без внешней программы.
  3. Предотвращение случайного падения приложения после перезагрузки кода. Пофиксив код, можно случайно разыменовать невалидный указатель в новом коде, после этого придется перезапускать приложение. Ничего страшного, но все же. Звучит как черная магия, я пока в раздумьях.

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

Демо

Поскольку реализация позволяет добавлять новые единицы трансляции налету, я решил записать небольшое видео, в котором я с нуля пишу неприлично простую игру про космический корабль, бороздящий просторы вселенной и расстреливающий квадратные астероиды. Писать старался не в стиле "все в одном файле", а по возможности раскладывая все по полочкам, тем самым порождая множество небольших файлов (поэтому вышло так много писанины). Конечно, для рисования, инпутов, окна и прочего используется фреймворк, но код самой игры писался с нуля.
Основная фишка — я только 3 раза запускал приложение: в самом начале, когда в нем была только пустая сцена, и 2 раза после падения по моей неосторожности. Вся игра инкрементально подливалась в процессе написания кода. Реального времени — около 40 минут. В общем, милости прошу.

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

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

Автор: ddovod

Источник


* - обязательные к заполнению поля