Хабрахабр

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

Для понимания того, что происходит, и кто все эти люди, рекомендую прочитать предыдущую статью.
*Ссылка на библиотеку и демо видео в конце статьи.

"Код" в данном случае — это функции, данные и их согласованная работа друг с другом. В прошлой статье мы ознакомились с подходом, позволяющим осуществлять "горячую" перезагрузку 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++ очень гибок и богат. В то же время вам нужно иметь реализацию класса Any со сколь-нибудь типобезопасным интерфейсом: Например, представьте, что в вашем проекте не используется rtti.

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. При использовании rtti нам доступны такие вещи, как type_info и, что больше нам подходит, type_index. В этом случае достаточно распространенным хаком (или элегантным решением?) является такая функция:

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 типов релокаций. На разных системах набор возможных релокаций свой. Все типы релокаций условно можно поделить на 2 группы: На macOS на x86-64 их всего 9.

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

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

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

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

  • Релокации из секции .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

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

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

resultAddr = newVarAddr + addend - relocAddr

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

resultAddr = resultAddr - newVarAddr + oldVarAddr

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

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

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

Она заставляет компилятор генерировать код без каких-либо предположений о расстоянии между символами, тем самым все адреса подразумеваются 64-битными. Покурив документацию к компиляторам и линковщикам, я решил попробовать опцию -mcmodel=large. Я так и не понял, в чем проблема, возможно на macOS нет подходящих релокаций для такой ситуации. Но эта опция не дружит с PIC, как будто -mcmodel=large нельзя использовать вместе с -fPIC, по крайней мере на 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, даже с учетом того, что адрес выровнен по размеру страницы. 30, и, в отличие от LLVM lld, он по умолчанию выставляет выравнивание сегмента PT_LOAD в 0x20000, что сильно выбивается из общей картины. У меня в системе GNU ld 2. Я потратил день, пока не понял, почему библиотека не грузится туда, куда надо. Чтобы обойти это, нужно кроме опции -Ttext-segment=... указать -z max-page-size=0x1000.

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

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

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

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

Показать больше

Похожие публикации

Добавить комментарий

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

Кнопка «Наверх»