bmstu-iu9 / refal-5-lambda

Компилятор Рефала-5λ
https://bmstu-iu9.github.io/refal-5-lambda
Other
79 stars 35 forks source link

Динамическая загрузка модулей #76

Closed Mazdaywik closed 4 years ago

Mazdaywik commented 7 years ago

Эта задача — родительская для нескольких подзадач. В ней будет описана общая концепция динамической загрузки, детали реализации будут в подзадачах.

Задача динамической загрузки

Программа на Рефале есть набор функций. Следует реализовать механизм, позволяющий создавать готовую для выполнения программу, функции которой могут находиться в нескольких отдельных файлах. Один из файлов (содержащий функцию Go) должен быть либо исполнимым файлом операционной системы, либо файлом интерпретируемого кода (см. #48) — будем называть его исполнимым модулем. Остальные файлы должны быть либо файлами интерпретируемого кода, либо библиотеками динамической загрузки операционной системы (но они также могут содержать интерпретируемый код) — будем называть их подключаемыми модулями. Исполнимые и подключаемые модули будем называть общим словом скомпилированные модули.

Скомпилированные модули могут зависеть от других подключаемых модулей — загрузка таких модулей должна вызывать загрузку зависимых модулей. Следствие: формат модулей должен содержать ссылки на зависимые модули.

Компилятор должен уметь порождать модули четырёх типов:

Требования:

Подходы

Динамические библиотеки ОС (.dll или .so) экспортируют только сущности одного вида — функции в Windows, символы в Linux. При этом на уровне средств ОС типы функций и символов не определены — их соответствие должен контролировать сам программист. (Да, компиляторы C++ поддерживают декорирование имён, но базовые механизмы — ОС Windows или загрузчики ld-linux.so и libdl — о них ничего не знают).

Аналогично, подключаемые модули Рефала-5λ тоже будут экспортировать только сущности одного вида — функции Рефала. Поскольку контроля типов функций в языке нет, то и экспортируемые функции также будут иметь одинаковый вид.

Далее будут рассмотрены сходства и отличия подходов Windows и Linux при реализации динамических библиотек. Реализация динамических библиотек в других вычислительных средах намеренно не рассматривалась (т.к. у меня (@Mazdaywik’а) опыта работы с ними нет).

Общее подходов Linux и Windows

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

Оба подхода позволяют иметь в библиотеке перехватчики загрузки и выгрузки (DllMain() и _init()/_fini()).

Загружаемые библиотеки имеют счётчик связей — при повторной загрузке библиотеки с тем же именем счётчик инкрементируется, библиотека выгружается, когда счётчик дошёл до нуля.

Подход Windows

В Windows динамические библиотеки являются пространствами имён. Если в библиотеках foo.dll и bar.dll одновременно присутствует функция Hello, то конфликта не будет — в таблицах импорта для каждой библиотеки указываются перечисляются функции, которые из неё импортируются. Хотя, написать приложение, которое одновременно использует две одноимённые функции из разных библиотек, подключаемых статически, скорее всего, будет нетривиальной задачей.

Чтобы функцию экспортировать из библиотеки, её нужно добавить в .def-файл или пометить нестандартным атрибутом __dllexport (или __declspec(dllexport), или __attribute__((dllexport)) — точный вид зависит от используемого компилятора Си. Функции, импортируемые из библиотек, помечаются нестандартным атрибутом __dllimport (синтаксис тоже может быть разным), который, вроде как, необязателен.

Библиотека может содержать функцию DllMain(), которая будет вызываться при (а) загрузке библиотеки, (б) при выгрузке библиотеки, (в) при создании или уничтожении потока в процессе. Важной особенностью, о которой нужно помнить, является то, что внутри этой библиотеки нельзя использовать сишный рантайм и немалую часть WinAPI — это ведёт к неопределённому поведению. Из DllMain() вызываются конструкторы и деструкторы глобальных объектов — при проектировании библиотеки это нужно учитывать.

Подход Linux

В Linux библиотеки и функции независимы: если исполнимый файл имеет неразрешённые (unresolved) ссылки на функции Hello и World, а также требует загрузки библиотек libfoo.so и libbar.so, то на этапе запуска приложения совершенно не важно, из какой из библиотек будет импортирована какая функция. Если две библиотеки экспортируют функции с одинаковыми именами, то скомпилированные модули будут связаны с той из них, которая загрузилась раньше.

Из библиотек экспортируются все символы, не помеченные как static в языке Си, импортируются все, которые линковщик не увидел в текущем модуле. Однако, если компилируется исполнимый файл, линковщик обязательно проверит, что в библиотеках, указанных в списке зависимостей, присутствуют неразрешённые в исполнимом файле символы. Если создаётся разделяемый объектный файл, такой проверки не делается.

Однако, загрузка позволяет более тонко управлять видимостью имён. В глобальную область видимости помещаются функции, загруженные из разделяемых объектных файлов при запуске, а также экспортируемые функции из exe-файла, если тот слинкован с флагом -rdynamic или --export-dynamic. Функция загрузки модуля dlopen() поддерживает следующие флаги, управляющие видимостью:

Поиск внешних ссылок при загрузке библиотеки осуществляется в списке зависимостей библиотеки и в ранее загруженных библиотеках с флагом RTLD_GLOBAL. Если исполнимый файл собран с ключом -rdynamic (или с --export-dynamic), то глобальные символы исполнимого файла также помещаются в глобальное пространство имён.

Библиотека может иметь функции _init() и _fini(), которые, соответственно, вызываются внутри dlopen() и dlclose(), однако, они считаются устаревшими. Вместо них следует использовать функции, помеченные атрибутами __attribute__((constructor)) и __attribute__((destructor)).

Выбранный подход

Выбирается подход, аналогичный подходу Linux, потому что он проще.

Этапы

Первым этапом является поддержка модулей типа I+, последним — N, второй и третий могут выполняться в любом порядке.

Mazdaywik commented 6 years ago

Деталь реализации: определение ранее загруженных модулей

Если модуль уже загружен, то повторно загружать его не надо. Возникает вопрос: а как определять повторную загрузку? По имени файла нельзя: один и тот же файл может быть доступен по разным путям: в путь можно добавить несколько раз ./././, можно добавлять пути «вверх» типа otherdir/../, на платформе Windows можно использовать разный регистр символов и чередовать знаки / и \.

Предлагается идентифицировать файлы по идентификатору устройства и идентификатору файла на устройстве. На POSIX эти параметры можно получить при помощи системного вызова stat и полей st_dev и st_ino:

http://www.opennet.ru/man.shtml?topic=stat&category=2&russian=2 http://www.opennet.ru/man.shtml?topic=stat&category=3&russian=5

На Windows аналогичную информацию можно получить при помощи GetInformationByHandle (поля с более длинными именами dwVolumeSerialNumber, nFileIndexHigh, nFileIndexLow):

https://technet.microsoft.com/ru-ru/office/aa364952(v=vs.110) https://technet.microsoft.com/ru-ru/office/aa363788(v=vs.110)

Вариант платформенно-независимого интерфейса для этой цели:

namespace refalrts::api {

struct stat;
const stat *stat_create(const char *filename);
signed stat_compare(const stat *left, const stat *right);
void stat_destroy(const stat *stat);

}

Соответственно, в структуре refalrts::api::stat будет находиться BY_HANDLE_FILE_INFORMATION для Windows и struct stat для POSIX. Функция stat_compare будет производить сравнение двух структур, возвращая -1, 0 или +1. Если файла не существует или произошла ошибка, stat_create будет возвращать NULL. Поэтому эту функцию можно использовать для более точной реализации ExistFile.

Mazdaywik commented 6 years ago

Порядок динамической загрузки модулей

Подход Windows

Если указан полный путь, то модуль загружается по полному пути. Ну, почти (см. про LOAD_WITH_ALTERED_SEARCH_PATH далее).

Если указано только имя без пути и расширения, то по умолчанию добавляется расширение .dll. Чтобы предотвратить автоматическое добавление .dll, имя файла должно заканчиваться на точку.

Если задано только имя модуля (возможно, с расширением), то сначала производится поиск среди загруженных ранее модулей — ищется модуль с совпадающим именем и расширением.

Если путь не указан или указан относительный путь, то выполняется процедура поиска. Процедура поиска весьма витиевата, зависит от кучи флагов и настроек среды. При загрузке функцией LoadLibraryEx можно указать LOAD_WITH_ALTERED_SEARCH_PATH. Можно в реестре установить SafeDllSearchMode (по умолчанию установлено, начиная с Windows XP SP2). Также может быть установлен дополнительный путь поиска при помощи SetDllDirectory. Рассмотрим разные варианты.

! LOAD_WITH_ALTERED_SEARCH_PATH && SafeDllSearchMode

  1. Папка исполнимого файла.
  2. Системная папка (%WINDIR%\system32).
  3. 16-разрядная системная папка.
  4. Папка Windows (%WINDIR%).
  5. Текущая папка.
  6. Папки в переменной %PATH%.

! LOAD_WITH_ALTERED_SEARCH_PATH && ! SafeDllSearchMode

  1. Папка исполнимого файла.
  2. Текущая папка.
  3. Системная папка.
  4. 16-разрядная системная папка.
  5. Папка Windows.
  6. %PATH%.

LOAD_WITH_ALTERED_SEARCH_PATH && SafeDllSearchMode

LOAD_WITH_ALTERED_SEARCH_PATH активизируется, только имя модуля задано полным путём.

  1. Папка полного пути имени модуля.
  2. Системная папка.
  3. 16-разрядная системная папка.
  4. Папка Windows.
  5. %PATH%.

LOAD_WITH_ALTERED_SEARCH_PATH && ! SafeDllSearchMode

  1. Папка искомого модуля.
  2. Текущая папка.
  3. Системная папка.
  4. 16-разрядная системная папка.
  5. Папка Windows.
  6. %PATH%.

SetDllDirectory()

  1. Папка исполнимого файла.
  2. Путь, заданный в SetDllDirectory().
  3. Системная папка.
  4. 16-разрядная системная папка.
  5. Папка Windows.
  6. %PATH%.

Флаги LOAD_LIBRARY_SEARCH_…

Мне их лень расписывать.

Как говорится, понять это невозможно, можно только запомнить.

Ссылки

Подход Linux

Согласно POSIX, если dlopen() видит / в строке имени файла, то он имя файла интерпретирует как путь, абсолютный или относительный. Иначе запускается платформенно-специфичная процедура поиска.

Процедура поиска на Linux включает следующие шаги:

  1. (только для ELF) Если исполнимый файл содержит тег DT_RPATH и не содержит тега DT_RUNPATH, то осуществляется поиск библиотеки в каталогах, перечисленных в DT_RPATH.
  2. Просматриваются пути поиска переменной среды LD_LIBRARY_PATH (данный пункт не применим к программам с битами SETUID и SETGID).
  3. (только для ELF) Если исполнимый файл содержит тег DT_RUNPATH, то осуществляется поиск в папках, перечисленных в этом теге.
  4. Просматривается сначала папка /lib, потом /usr/lib.

Параметр DT_RPATH считается deprecated, видимо, потому что считается кошернее сначала смотреть в LD_LIBRARY_PATH.

В отличие от Windows поиск в явном виде не включает ни текущей папки, ни папки исполнимого файла. Потому что если конкретному приложению нужен поиск в текущей папке, оно может положить . в LD_LIBRARY_PATH (перед запуском) или в DT_RPATH/DT_RUNPATH (во время компиляции). А для поиска относительно каталога исполнимого файла есть более гибкий вариант — строки, заданные в DT_RPATH/DT_RUNPATH, могут содержать подстроку $ORIGIN или ${ORIGIN}, которая загрузчиком заменяется на путь к исполнимому файлу. Пример из man:

Thus, an application located in somedir/app could be compiled with

gcc -Wl,-rpath,'$ORIGIN/../lib'

so that it finds an associated shared object in somedir/lib no matter where somedir is located in the directory hierarchy.

Ссылки

Предлагаемый подход

Предлагаемый подход частично комбинирует подходы Windows и Linux. Переменную $ORIGIN мы не берём, поскольку она сравнительно сложна в реализации. А значит, в путях поиска потребуется в явном виде каталог приложения.

Да и вообще, переменных, аналогичных DT_RPATH или DT_RUNPATH, предусматривать пока не будем. Они потребуют изменения формата RASL’а.

Стандартных путей для библиотек (типа %WINDIR%\System32 или /usr/lib предусматривать также не будем — компилятор может быть установлен куда угодно. Поэтому остаются только путь до исполнимого файла, переменная окружения и текущая папка.

Примечание. Возможно, в будущих версиях появятся стандартные пути для библиотек, а также аналоги DT_RPATH или DT_RUNPATH.

Очень удобным видится подход POSIX, когда поиск осуществляется только для имён без пути. Если имя файла содержит /, то оно считается путём.

Поэтому предлагается следующий алгоритм.

Имя модуля может заканчиваться на суффикс .rasl-module (файл без нативного кода) или платформенно-зависимый суффикс библиотеки (.dll или .so).

Mazdaywik commented 6 years ago

Дополнение к предыдущему комментарию.

Получение дескриптора исполнимого файла

На Windows есть функция GetModuleHandle, которая принимает имя модуля и возвращает дескриптор ранее загруженного модуля с тем же именем. Если имя задано как NULL, возвращается дескриптор исполнимого файла. Функции LoadLibrary и LoadLibraryEx вместо имени файла NULL принимать не могут.

На Linux dlopen может принимать NULL вместо имени файла с той же семантикой — возвращает дескриптор исполнимого файла.

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

Ссылки

Ленивая загрузка нативных модулей

Мы имеем некоторый файл без стандартного расширения. Как определить, нативный ли он. Нужно ли использовать функции операционной системы (dlopen, LoadLibrary) для попытки загрузки файла?

Можно пытаться это делать всегда. Если загрузка прошла успешно, то значит, что модуль нативный. Если нет — только с RASL. Эвристика: если код RASL начинается с нулевого смещения, значит нативного кода в файле заведомо нет.

Но предлагается другой вариант. Если в модуле есть как минимум одна нативная функция, то делается попытка загрузить модуль как нативный. Если нет — попытки не делается, даже если он нативный на самом деле.

Mazdaywik commented 4 years ago

Из того, что не сделано

Что надо сделать

Все дочерние задачи для текущей сделаны, все ссылки выделены красным. Остались только эти три пункта.

Mazdaywik commented 4 years ago

Ещё один тонкий момент

Проблема перестала воспроизводиться.