Трюки при линковке и загрузке файлов Mach-O

http://blog.darlinghq.org/2018/07/mach-o-linking-and-loading-tricks.html
  • Перевод

Представляю вашему вниманию перевод моей статьи из блога Проекта Darling. Маленькая справка по используемым понятиям: Darwin – операционная система с открытым исходным кодом, лежащая в основе macOS, iOS и других ОС от Apple; Mach-O – бинарный формат исполняемых файлов и библиотек, использующийся в Darwin; dyld – динамический загрузчик, использующийся в Darwin для загрузки файлов Mach-O; dylib – динамически загружаемая библиотека (обычно имеет расширение .dylib).


Картинка для привлечения внимания


Цель Проекта Darling – сделать возможным запуск macOS-приложений под Linux, и умение загружать бинарные файлы в формате Mach-O – один из ключевых шагов к достижению этой цели.


Исходно, Darling был выстроен вокруг собственной реализации загрузчика Mach-O и идеи транслирования вызовов между высокоуровневым Darwin API и его Linux-аналогами. С тех пор наш фокус сместился на запуск кода во всё более и более изолированном Darwin-контейнере. С тех пор как мы перешли на использование Mach-O для внутренних компонентов Darling, у нас появилась возможность использовать исходный dyld от Apple, а также собирать многие другие компоненты Darwin с открытым исходным кодом. Нам всё ещё нужен простой загрузчик Mach-O, чтобы загружать сам dyld.


Mach-O, вместе с самим Mach, – наверно, самые заметные отличительные черты Darwin, и разнообразные библиотеки и фреймворки, поставляемые Apple, активно используют множество малоизвестных особенностей формата Mach-O. Это делает работу с Mach-O одной из самых важных и заметных задач при разработке Darling. От реализации собственных загрузчиков Mach-O до сборки компонентов Darwin (сначала в виде специальных ELF-файлов, а сейчас в виде настоящих Mach-O) – нам нужно понимать внутреннее устройство Mach-O на гораздо более глубоком уровне, чем это обычно требуется от обыкновенных разработчиков под Darwin-платформы.


Закончим на этом введение и перейдём к обсуждению некоторых из трюков, которые нам может предложить формат Mach-O.


Установочные имена


В Windows и Linux на динамические библиотеки ссылаются по их имени (например, libc.so), после чего задачей динамического линковщика является найти библиотеку с совпадающим именем в одной из стандартных директорий для библиотек. В Darwin вместо этого сразу используется (почти) полный путь к файлу библиотеки, который называют установочным именем [install name] этой библиотеки. Предположительно, так сделали, чтобы предотвратить подмену dylib [dylib hijacking] – атаку, при которой поддельная dylib помещается в директорию, в которой она будет найдена динамическим линковщиком раньше настоящей, что позволяет поддельной dylib выполнять произвольный код от имени программы, которую таким образом убедили эту dylib загрузить.


Не только исполняемые файлы и библиотеки хранят полные установочные имена своих зависимостей, но и Mach-O-зависимости сами «знают» свои собственные установочные имена. Собственно, именно так линковщик узнаёт, какие установочные имена использовать для зависимостей: он прочитывает их из самих зависимостей.


При линковке dylib вы указываете её установочное имя, используя опции ld -install_name или -dylib_install_name:


$ ld -o libfoo.dylib foo.o -install_name /usr/local/lib/libfoo.dylib

Теперь, когда вы будете линковать другой Mach-O-файл (скажем, libbar.dylib) к libfoo.dylib, ld запишет устаноночное имя libfoo/usr/local/lib/libfoo.dylib – в списке зависимостей libbar, и именно там dyld будет искать libfoo во время выполнения.


Использование полного пути работает достаточно хорошо для системных библиотек, которые, действительно, устанавливаются в заранее известные места в файловой системе; но с библиотеками, которые поставляются как часть сборок приложений [app bundles], возникает проблема. Хотя каждое приложение могло бы предполагать, что оно будет установлено в /Applications/AppName.app, вообще говоря, имеется в виду, что сборки приложений переносимы и могут быть свободно перемещены по файловой системе, так что конкретный путь к библиотекам внутри таких сборок не может быть известен заранее.


В качестве решения этой проблемы Darwin разрешает установочным именами начинаться с @executable_path, @loader_path, или @rpath – то есть быть не абсолютными, а указывать путь к библиотеке относительно пути к основному исполняемому файлу, пути к «загружающему» (исполняемому файлу или библиотеке, которая напрямую зависит от этой библиотеки) или относительно списка путей, определяемого основным исполняемым файлом, соответственно. @executable_path и @loader_path работают без дополнительных сложностей, но если хотя бы одна из ваших зависимостей (или их транзитивных зависимостей) имеет установочное имя, использующее @rpath, вы должны явно задать @rpath при линковке вашего исполняемого файла, используя опцию ld -rpath столько раз, сколько вам нужно:


$ ld -o runme -rpath @executable_path/../Frameworks -rpath @executable_path/../bin/lib

(Концепция rpath в некоторой степени разрушает исходную идею заранее известных путей к библиотекам, и открывает возможность проведения атак по подмене dylib. Можно считать, что это делает всё, связанное с установочными именами, довольно-таки бесполезным.)


Циклические зависимости


Когда исходный код проекта занимает несколько файлов, совершенно нормально, если эти файлы взаимно зависят друг от друга. Это отлично работает, пока все эти файлы компилируются в один бинарник – исполняемый файл или библиотеку. Что не работает, так это когда несколько динамических библиотек зависят друг от друга.


Вы можете возразить, что вместо использования циклических зависимостей между динамическими библиотеками стоит перепроектировать архитектуру проекта, и я с вами соглашусь. Но если есть что-то типичное для Apple, так это то, что они никогда не останавливаются, чтобы всё обдумать и сделать правильно; вместо этого они наслаивают костыли и трюки один на другой. В этом случае нам для Darling нужно заставить циклические зависимости работать, поскольку разнообразные подбиблиотеки libSystem, такие как libsystem_dyld, libsystem_kernel и libsystem_pthread, все зависят друг от друга. (До недавнего времени нам также приходилось циклически линковать Cocoa-фреймворки, такие как AppKit, Core Graphics и Core OpenGL, из-за того, как реализован Core OpenGL в The Cocotron, но мы перепроектировали архитектуру нашей реализации Core OpenGL и смогли избавиться от этой циклической зависимости.)


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


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


$ ld -o libfoo.dylib foo.o -flat_namespace -undefined suppress
$ ld -o libbar.dylib bar.o -flat_namespace -undefined suppress

(Смотрите ниже насчёт -flat_namespace.)


Конечно, если вы попробуете напрямую использовать получившиеся dylib-ки, вы получите ошибки динамической линковки во время выполнения. Вместо этого перелинкуйте эти библиотеки во второй раз, передавая полученные dylib-ки в качестве зависимостей:


$ ld -o libfoo.dylib foo.o libbar.dylib
$ ld -o libbar.dylib bar.o libfoo.dylib

На этот раз линковщик видит все символы, поэтому мы не говорим ему игнорировать ошибки (и если каких-то символов действительно не хватает, вы получите ошибку).


Не смотря на то что некоторые, если не все, из библиотек слинковались к «неправильным» копиям своих зависимостей, во время выполнения dyld увидит правильные версии. Чтобы это сработало, удостоверьтесь, что у обеих копий каждой библиотеки одинаковое установочное имя.


Ещё одна деталь здесь – порядок инициализации. Любой код может объявить функции-инициализаторы используя магическую команду компилятора __attribute__((constructor)) (список таких инициализаторов попадает в секцию __mod_init_func в Mach-O-файле). Эти функции вызываются dyld при загрузке бинарника, в котором они находятся, перед вызовом main(). Обычно, инициализаторы каждой библиотеки выполняются после инициализаторов её зависимостей, поэтому каждый инициализатор может рассчитывать на то, что библиотеки-зависимости уже инициализированы и готовы к работе. Конечно, этого нельзя гарантировать для циклических зависимостей; dyld выполнит их инициализаторы в каком-то порядке. Вы можете отмечать зависимости как зависимости вверх [upward dependencies], чтобы настроить этот порядок; dyld проинициализирует библиотеки, которые кто-нибудь отметил как свою зависимость вверх, последними. Так что, чтобы заставить libfoo проинициализироваться после libbar, слинкуйте их так:


$ ld -o libfoo.dylib foo.o libbar.dylib
$ ld -o libbar.dylib bar.o -upward_library libfoo.dylib

Чтобы сделать всё это удобнее, у нас в Darling есть CMake-функция под названием add_circular, которая берёт на себя все сложности и позволяет использовать её вот так просто и декларативно:


set(DYLIB_INSTALL_NAME "/usr/lib/system/libdispatch.dylib")
add_circular(libdispatch_shared FAT
        SOURCES
                ${dispatch_SRCS}
        SIBLINGS
                system_c
                system_kernel
                system_malloc
                system_blocks
                system_pthread
                system_dyld
                system_duct
                unwind
                platform
                compiler_rt
        UPWARD
                objc
)

Двухуровневое пространство имён символов


Таблицы символов в Mach-O не просто хранят имена символов, они ещё и «помнят», из какой библиотеки (или исполняемого файла) какой символ берётся. Другими словами, имена символов существуют в пространствах имён, задаваемых тем, какой бинарник их определяет; отсюда «двухуровневое пространство имён» [two-level namespace] (под ещё одним уровнем имеются в виду сами имена символов).


Двухуровневое пространство имён ввели для предотвращения конфликтов имён символов. Обычно, если несколько библиотек определяют символы с одинаковым именем, вы получите ошибку во время линковки; но это может не сработать, если загружать библиотеки во время выполнения (например, плагины) или когда версии библиотек во время линковки и время выполнения различаются. Для библиотек, которые используют двухуровневое пространство имён, это не проблема – оно позволяет нескольким библиотекам определять символы с одинаковым именем, не создавая конфликтов.


Двухуровневое пространство имён можно отключить, вернувшись к использованию «плоского пространства имён» (одна из причин это сделать – то, что использование двухуровневого пространства имён подразумевает, что каждый символ должен быть разрешён во время линковки, поэтому для -undefined_suppress требуется плоское пространство имён, как мы видели выше). У ld есть два флага, позволяющие отключить двухуровневое пространство имён во время линковки: -flat_namespace, который влияет только на один Mach-O-файл, и -force_flat_namespace, который работает только с исполняемыми файлами, а не библиотеками, и заставляет весь процесс использовать плоское пространство имён. Кроме того, можно во время выполнения заставить dyld использовать плоское пространство имён, если установить переменную окружения DYLD_FORCE_FLAT_NAMESPACE.


Одна особенность использования двухуровневого пространства имён состоит в том, что вам всегда приходится явно линковать каждый Mach-O ко всем библиотекам и фреймворкам, от которых он зависит. Например, если вы линкуетесь к AppKit, вы не сможете просто так использовать Foundation; вам придётся прилинковаться явно и к ней. Другая особенность – в том, что, как автор библиотеки или фреймворка, вы не можете свободно перемещать реализацию символа «вниз» по цепочке зависимостей, как вы могли привыкнуть делать (например, нельзя просто так перемещать код из AppKit в Foundation). Чтобы позволить делать это, у Mach-O, ld и dyld есть несколько дополнительных возможностей, а именно подбиблиотеки, переэкспортирование символов и мета-символы.


Подбиблиотеки


Подбиблиотеки – механизм, позволяющий одной библиотеке (называемой фасадной библиотекой [facade library] или umbrella-библиотекой [umbrella library]) делегировать реализацию части своей функциональности другой библиотеке (называемой её подбиблиотекой [sub-library]); или, если на это посмотреть с другой стороны, позволяющий библиотеке публично переэкспортировать символы, предоставляемые другой библиотекой.


Основное место, где это используется – опять же, libSystem с её подбиблиотеками, которые лежат в /usr/lib/system; но это можно использовать с любой парой библиотек:


$ ld -o libfoo.dylib foo.o -lobjc -sub_library libobjc
# или:
$ ld -o libfoo.dylib foo.o -reexport-lobjc

Единственное, на что это влияет по сравнению с просто линковкой к той библиотеке – это что в результирующий файл записывается команда LC_REEXPORT_DYLIB вместо обычной LC_LOAD_DYLIB (в том числе, символы из подбиблиотеки во время линковки не копируются в umbrella-библиотеку, так что её даже не приходится перелинковывать, если в подбиблиотеку позднее добавляются новые символы). Во время выполнения LC_REEXPORT_DYLIB тоже работает похоже на LC_LOAD_DYLIB: dyld загрузит подбиблиотеку и сделает её символы доступными остальным (но в отличие от LC_LOAD_DYLIB, с точки зрения двухуровневого пространства имён символы будут происходить из umbrella-библиотеки).


Что действительно отличается насчёт LC_REEXPORT_DYLIB – это что ld делает, когда вы линкуете ещё одну библиотеку к libfoo: вместо того, чтобы просто искать символы во всех объектных и dylib-файлах, которые ему передали, ld также откроет и просмотрит и переэкспортированную подбиблиотеку (в этом примере libobjc).


Откуда он знает, где её искать? Единственное, что сохранено в libfoo.dylib – это установочное имя libobjc.dylib, поэтому именно там ld ожидает её найти. Это означает, что библиотека должна быть установлена на своё место, прежде чем её можно будет использовать в качестве подбиблиотеки для чего угодно ещё; это нормально работает для системных библиотек вроде libobjc, но может быть очень неудобно или совсем невозможно, если вы пытаетесь переэкспортировать собственную подбиблиотеку.


Чтобы решить эту проблему, ld предоставляет опцию -dylib_file, которая позволяет вам указать другой путь к dylib для использования во время линковки:


$ ld -o libfoo.dylib foo.o -reexport_library /path/to/libsubfoo.dylib
$ ld -o libbar.dylib bar.o libfoo.dylib -dylib_file @executable_path/lib/foo/libsubfoo.dylib:/path/to/libsubfoo.dylib

Хотя libSystem и некоторые другие системные библиотеки переэкспортируют свои подбиблиотеки, вам не приходится использовать -dylib_file при линковке каждого из исполняемых файлов на macOS; это потому, что системные библиотеки уже установлены в соответствии со своим установочным именем. Но при сборке Darling на Linux нам приходится передавать несколько опций -dylib_file (и других общих аргументов) каждому вызову ld. Мы делаем это при помощи специальной функции, которая автоматически применяется при использовании add_darling_library, add_darling_executable и других.


Переэкспортирование символов


Иногда библиотеке может быть нужно переэкспортировать часть символов – но не сразу всё – из другой библиотеки. Например, Core Foundation переэкспортирует NSObject, который в последних версиях реализован внутри Objective-C runtime, ради совместимости.


(Если вас интересует, почему NSObject вообще когда-то был в Core Foundation вместо Foundation, то это из-за того, что то, как реализована бесплатная конвертация [toll-free bridging, возможность напрямую кастовать между соответствующими Core Foundation и Foundation-типами без дополнительной конвертации], требует, чтобы приватные классы-обёртки над типами из Core Foundation (например, __NSCFString) были реализованы в Core Foundation; а будучи Objective-C-объектами, они должны наследоваться от NSObject. Наверно, всё это можно было бы реализовать по-другому, оставив NSObject со всеми его наследниками в Foundation и циклически слинковав Core Foundation и Foundation, но Apple решили перенести эти вспомогательные приватные классы вместе с NSObject в Core Foundation, и в Darling мы делаем это точно так же, чтобы сохранить совместимость.)


Вы можете передать ld список символов, которые нужно переэкспортировать, используя его опцию -reexported_symbols_list:


$ echo .objc_class_name_NSObject > reexport_list.exp
$ ld -o CoreFoundation CFFiles.o -lobjc -reexported_symbols_list reexport_list.exp

Хотя переэкспортирование некоторых символов звучит очень похоже на переэкспортирование всех символов, механизм, с помощью которого это реализовано, сильно отличается от того, как работают подбиблиотеки. Не используется никакой специальной LC_*_DYLIB-команды; вместо этого, в таблицу имён вставляется специальный косвенный символ (обозначаемый флагом N_INDIR), и он ведёт себя как символ, определённый в этой библиотеке. Если сама библиотека использует этот символ, в таблице имён окажется вторая, «неопределённая» копия символа (как это происходит и без всякого переэкспортирования).


Одна важная мелочь, о которой стоит помнить при использовании явного переэкспортирования символов – вам, скорее всего, нужно переэкспортировать символы с разными именами для разных архитектур. Действительно, соглашение о преобразовании имён [name mangling convention] для Objective-C и бинарный интерфейс [ABI] Objective-C для архитектур i386 и x86-64 различаются, так что на i386 вам нужно переэкспортировать только .objc_class_name_NSObject, а на x86-64 – _OBJC_CLASS_$_NSObject, _OBJC_IVAR_$_NSObject.isa и _OBJC_METACLASS_$_NSObject. Об этом не приходится думать при использовании подбиблиотек, поскольку там автоматически переэкспортируются все доступные символы для каждой архитектуры.


Большинство инструментов для работы с Mach-O прозрачно разбираются с «толстыми», или универсальными, бинарниками (файлами Mach-O, содержащими несколько под-Mach-O для нескольких архитектур). Clang может собирать универсальные бинарники со всеми запрошенными архитектурами, dyld выбирает, какую архитектуру загружать из dylib, глядя на то, какие архитектуры поддерживает исполняемый файл, а такие инструменты, как ld, otool и nm работают с архитектурой, соответствующей архитектуре компьютера (т.е. x86-64), если явно не потребовать другую архитектуру специальным флагом. Единственное, что всё-таки напоминает о том, что обрабатываются несколько архитектур – это то, что при компиляции вы получаете ошибки и предупреждения дважды, по одному разу для каждой архитектуры.


Необходимость передавать два разных списка переэкспортирования разрушает эту иллюзию. У ld нет встроенной опции для использования разных списков для разных архитектур, что означает, что нам придётся линковать dylib-ки для каждой архитектуры отдельно и потом объединять их с помощью lipo:


$ ld -o CF_i386.dylib CFFiles.o -arch i386 -lobjc -reexported_symbols_list reexport_i386.exp
$ ld -o CF_x86-64.dylib CFFiles.o -arch x86_64 -lobjc -reexported_symbols_list reexport_x86_64.exp
$ lipo -arch i386 CF_i386.dylib -arch x86_64 CF_x86-64.dylib -create -output CoreFoundation

В Darling мы используем CMake-функцию под названием add_separated_framework, абстрагирующую внутри себя раздельную линковку и вызов lipo, так что настоящий CMake-скрипт для сборки Core Foundation выглядит примерно так:


add_separated_framework(CoreFoundation
        CURRENT_VERSION
        SOURCES
                ${cf_sources}
        VERSION "A"
        DEPENDENCIES
                objc
                system
                icucore
        LINK_FLAGS
                # ...здесь остальные общие флаги
)
set_property(TARGET CoreFoundation_i386 APPEND_STRING PROPERTY
  LINK_FLAGS " -Wl,-reexported_symbols_list,${CMAKE_CURRENT_SOURCE_DIR}/reexport_i386.exp")
set_property(TARGET CoreFoundation_x86_64 APPEND_STRING PROPERTY
  LINK_FLAGS " -Wl,-reexported_symbols_list,${CMAKE_CURRENT_SOURCE_DIR}/reexport_x86_64.exp")

Мета-символы


Мета-символы – ещё одна особенность, добавленная, чтобы дать Apple возможность перемещать символы и библиотеки, не ломая старый код.


При сборке Mach-O-файла всегда стоит указывать самую раннюю версию macOS, которую он поддерживает, с помощью опции компилятора -mmacosx-version-min=10.x (или аналогичных опций для iOS, tvOS, watchOS и остальных названий ОС, которые Apple в будущем придумает для своих продуктов). Эта опция контролирует несколько вещей; в том числе, она включает и выключает разнообразные макросы доступности вроде AVAILABLE_MAC_OS_X_VERSION_10_13_AND_LATER и переключает реализацию стандартной библиотеки C++ между libstdc++ (версия от GNU) и libc++ (версия от LLVM). В этом посте нас интересует то, как она влияет на линковщик и получаемый Mach-O. Среди прочего, и у ld есть опция -macosx_version_min (обратите внимание на символы подчёркивания и отсутствие второй m), которая заставляет его выдать Mach-O-команду LC_VERSION_MIN_MACOSX (сообщающую dyld, что следует выбросить ошибку, если этот файл загружается на более ранней версии).


В дополнение к этому, передача ld опции -macosx_version_min влияет на то, какие мета-символы других Mach-O-файлов учитываются. Мета-символы – это символы, имена которых начинаются с $ld$, и ld, когда встречает такой символ, обрабатывает его особенным образом: он считает его дополнительной командой, а не символом. Его имя должно иметь форму $ld$действие$условие$имя. Здесь условие выглядит как os10.5 и определяет, для какой версии ОС предназначен этот мета-символ – точнее говоря, этот символ будет учтён только если заявленная минимальная поддерживаемая линкуемым Mach-O-файлом версия ОС равна версии, указанной этим символом; действие может быть add, hide, install_name или compatibility_version, что заставляет ld притвориться, что он видит или не видит символ с именем имя, использовать имя в качестве установочного имени или в качестве версии совместимости (смотрите ниже) этой бибилиотеки, соответственно.


Поскольку условие не может описывать промежуток версий, вы, скорее всего, увидите одно и то же действие повторённым множество раз для разных версий ОС; например, вот список мета-символов, которые использует libobjc, чтобы спрятать NSObject от кода, рассчитанного на более ранние версии macOS:


$ld$hide$os10.0$_OBJC_CLASS_$_NSObject
$ld$hide$os10.0$_OBJC_IVAR_$_NSObject.isa
$ld$hide$os10.0$_OBJC_METACLASS_$_NSObject
$ld$hide$os10.1$_OBJC_CLASS_$_NSObject
$ld$hide$os10.1$_OBJC_IVAR_$_NSObject.isa
$ld$hide$os10.1$_OBJC_METACLASS_$_NSObject
$ld$hide$os10.2$_OBJC_CLASS_$_NSObject
$ld$hide$os10.2$_OBJC_IVAR_$_NSObject.isa
$ld$hide$os10.2$_OBJC_METACLASS_$_NSObject
$ld$hide$os10.3$_OBJC_CLASS_$_NSObject
$ld$hide$os10.3$_OBJC_IVAR_$_NSObject.isa
$ld$hide$os10.3$_OBJC_METACLASS_$_NSObject
$ld$hide$os10.4$_OBJC_CLASS_$_NSObject
$ld$hide$os10.4$_OBJC_IVAR_$_NSObject.isa
$ld$hide$os10.4$_OBJC_METACLASS_$_NSObject
$ld$hide$os10.5$_OBJC_CLASS_$_NSObject
$ld$hide$os10.5$_OBJC_IVAR_$_NSObject.isa
$ld$hide$os10.5$_OBJC_METACLASS_$_NSObject
$ld$hide$os10.6$_OBJC_CLASS_$_NSObject
$ld$hide$os10.6$_OBJC_IVAR_$_NSObject.isa
$ld$hide$os10.6$_OBJC_METACLASS_$_NSObject
$ld$hide$os10.7$_OBJC_CLASS_$_NSObject
$ld$hide$os10.7$_OBJC_IVAR_$_NSObject.isa
$ld$hide$os10.7$_OBJC_METACLASS_$_NSObject

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


Резолверы символов


Ещё одна довольно интересная особенность dyld – его поддержка резолверов символов [symbol resolvers], способа кастомизации процесса разрешения символов. Вы пишете резолвер символа, специальную функцию, которая может реализовывать любую кастомную логику для нахождения адреса символа, и dyld вызывает её во время выполнения, когда требуется разрешить этот символ.


Для использования резолверов символов не нужны хитрые флаги ld, это полностью делается в коде. На уровне ассемблера резолверы символов создаются путём использования специальной псевдооперации .symbol_resolver:


; две разных реализации foo
_foo1:
    movl 1, %eax
    ret
_foo2:
    movl 2, %eax
    ret

.symbol_resolver _foo
    ; проверяем какое-нибудь условие
    call _condition
    jz .ret_foo2
    movq _foo1, %rax
    ret
.ret_foo2:
    movq _foo2, %rax
    ret

; Нам также нужно, чтобы сам _foo присутствовал
; в таблице символов, но его значение не важно,
; поскольку его заменит результат резолвера.
.global _foo
_foo:

Поскольку на уровне C для резолверов символов нет никакой специальной поддержки со стороны компилятора, приходится использовать ассемблерные вставки, чтобы сделать это из C:


static int foo1() {
    return 1;
}

static int foo2() {
    return 2;
}

int foo() {
    // тело этой функции ни на что не влияет
    return 0;
}

static void *foo_resolver() {
    __asm__(".symbol_resolver _foo");
    return condition() ? &foo1 : &foo2;
}

(В ассемблерном коде мы пишем _foo вместо просто foo, потому что под Darwin действует соглашение о преобразовании имён даже для C, состоящее в добавлении к каждому имени символа подчёркивания в начале. До того, как мы перешли на использование Mach-O для внутренних компонентов Darling, нам приходилось добавлять и отрезать назад это подчёркивание при работе с ELF-файлами, что принесло нам немало боли.)


Поскольку содержимое _foo ни на что не влияет, как ни на что не влияет и имя резолвера (а в ассемблерном листинге выше у него и вовсе не было имени), обычно foo() и foo_resolver() объединяют в одно определение функции, вот так:


void *foo() {
    __asm__(".symbol_resolver _foo");
    return condition() ? &foo1 : &foo2;
}

Один недостаток того, чтобы так делать – вы можете получить ошибки из-за того, что прототип foo() отличается от того, что указан в заголовочном файле (здесь она возвращает обобщённый указатель вместо int-а). Кроме того, обратите внимание, что работающая здесь магия не особо надёжна: вызов dlsym("_foo") вернёт исходный адрес _foo – тот самый, про который мы только что решили, что он ни на что не влияет, – так что в этом случае это будет адрес резолвера. Наверно, если вам нужно заботиться об этом случае, более осмысленным будет сделать, чтобы одна из потенциальных реализаций foo() играла роль символа _foo.


Можно представить себе всевозможные креативные способы использования резолверов символов. Сами Apple используют их в libplatform для выбора наиболее эффективной реализации примитивов блокировки во время выполнения на основе обнаруженных количества ядер процессора и поддержки им наборов инструкций:


#define _OS_VARIANT_RESOLVER(s, v, ...) \
    __attribute__((visibility(OS_STRINGIFY(v)))) extern void* s(void); \
    void* s(void) { \
    __asm__(".symbol_resolver _" OS_STRINGIFY(s)); \
        __VA_ARGS__ \
    }

#define _OS_VARIANT_UPMP_RESOLVER(s, v) \
    _OS_VARIANT_RESOLVER(s, v, \
        uint32_t *_c = (void*)(uintptr_t)_COMM_PAGE_CPU_CAPABILITIES; \
        if (*_c & kUP) { \
            extern void OS_VARIANT(s, up)(void); \
            return &OS_VARIANT(s, up); \
        } else { \
            extern void OS_VARIANT(s, mp)(void); \
            return &OS_VARIANT(s, mp); \
        })

Эти макросы генерируют резолверы, проверяющие – во время выполнения – является ли машина однопроцессорной (по наличию флага kUP в дескрипторе возможностей процессора, расположенном на commpage), чтобы, например, использовать немного более эффективную реализацию спинлока [spinlock]. Эта проверка выполняется только один раз для каждого символа при его загрузке, после чего символ напрямую привязывается к выбранной реализации и последующие вызовы не требуют дополнительных накладных расходов.


В Darling мы дополнительно используем резолверы символов для ещё более амбициозной задачи: чтобы позволить нашим Mach-O-бинарникам прозрачно использовать ELF-библиотеки из Linux, установленного на хосте [host, что противопоставляется изолированному контейнеру, в котором собственно и работает Darling] – библиотеки, такие как libX11 и libcairo.


Первый шаг к использованию ELF-библиотек – это libelfloader, наша реализация простого загрузчика ELF, у которой как раз достаточно функциональности для того, чтобы загрузить ld-linux, аналог dyld из Linux, и прыгнуть в ld-linux для загрузки самих нужных нам ELF-библиотек. Мы собираем сам libelfloader в Mach-O и устанавливаем его как /usr/lib/darling/libelfloader.dylib внутри нашей Darwin-chroot-директории; таким образом, его можно напрямую использовать из нашего Darwin-кода.


Важная деталь здесь в том, что libelfloader намеренно не объединяет пространства имён символов Mach-O и ELF. Кроме одного указателя (_elfcalls), запрятанного в глубине libSystem, весь Darwin остаётся в блаженном неведении насчёт того, что в адресное пространство теперь отображены несколько ELF-библиотек для Linux. «Миры» Darwin и Linux на удивление мирно сосуществуют в рамках одного процесса – в том числе, каждый использует свою собственную библиотеку C (libSystem_c и glibc, соответственно).


Чтобы получить доступ к ELF-символам из мира Darwin, можно использовать заклинания-вызовы libelfloader API вроде _elfcalls->dlsym_fatal(_elfcalls->dlopen_fatal("libX11.so"), "XOpenDisplay"). Дальше, у нас есть инструмент под названием wrapgen, делающий использование ELF-символов проще и гораздо прозрачнее, и позволяющий нам использовать код других проектов, таких как The Cocotron – которые могут ожидать иметь возможность напрямую вызывать библиотеки для Linux – без серьёзных изменений. Если передать wrapgen название ELF-библиотеки (например, libX11.so), он достаёт список её символов и автоматически генерерует код вроде такого:


#include <elfcalls.h>
extern struct elf_calls* _elfcalls;

static void* lib_handle;
__attribute__((constructor)) static void initializer() {
        lib_handle = _elfcalls->dlopen_fatal("libX11.so");
}

__attribute__((destructor)) static void destructor() {
        _elfcalls->dlclose_fatal(lib_handle);
}

void* XOpenDisplay() {
        __asm__(".symbol_resolver _XOpenDisplay");
        return _elfcalls->dlsym_fatal(lib_handle, "XOpenDisplay");
}

Дальше мы собираем этот код в Mach-O-библиотеку и устанавливаем её в /usr/lib/native/libX11.dylib, и другие Mach-O-библиотеки могут просто линковаться к ней, как будто она и есть libX11.so, магически превращённая в Mach-O. Само собой, у нас есть CMake-функция под названием wrap_elf, которая упрощает вызов wrapgen, сборку Mach-O-заглушки и установку её в /usr/lib/native: вы просто вызываете wrap_elf(X11 libX11.so) и линкуете другие библиотеки к libX11, как если бы это была просто ещё одна Mach-O-библиотека.


Возможность загружать и использовать библиотеки для Linux настолько просто и прозрачно ощущается как обладание суперспособностью. Как я уже упомянул, в прошлом Darling был тонкой прослойкой, почти напрямую отображавшей библиотечные функции Darwin на библиотечные функции Linux, но те дни давно прошли. Теперь Darling – это очень совместимая реализация Darwin (а точнее, порт Darwin); благодаря, отчасти, тому, что мы можем напрямую переиспользовать большие части оригинального исходного кода Darwin, такие как libSystem, dyld, XNU и launchd, а отчасти нашей готовности реализовывать множество недокументированных мелочей, которые нужны для этого кода, вроде вышеупомянутой commpage. И хотя некоторым очень низкоуровневым частям стека, таким как libsystem_kernel, приходится разбираться с тем, что на самом деле они запущены поверх ядра Linux, большинство кода «видит» только обычное Darwin-окружение – Linux и GNU/Linux нигде не найти. И именно поэтому прямой и лёгкий доступ к нативной Linux-библиотеке или подключение к сервису, запущенному под Linux на хосте (такому как X server) ощущается как вынимание кролика из шляпы, как наблюдение за фокусом [witnessing a magic trick] – чем эти трюки с libelfloader, резолверами символов и wrapgen, собственно, и являются. Но это фокус, который становится только более, а не менее, впечатляющим, когда узнаёшь, как он работает.


Порядок символов


Если вы зачем-то полагаетесь на порядок, в котором символы окажутся в Mach-O-файле, вы можете поручить ld расположить их именно в таком порядке. (Я считаю, что полагаться на это – нездоровая идея, но Apple, конечно же, думает иначе.)


Чтобы сделать это, запишите список символов, для которых требуется специальный порядок, в этом порядке в специальный файл, называемый файлом порядка [order file], и потом передайте его ld вот таким образом:


$ ld -o libfoo.dylib foo.o -order_file foo.order

В отличие от опции -reexported_symbols_list, про которую шла речь выше, -order_file поддерживает больше, чем просто список имён:


symbol1
symbol2
# Это комментарий.
#
# Вы можете явно указать, к какому файлу относится символ,
# иначе имена приватных (называемые статическими в C) символов
# могут встретиться несколько раз в разных объектных файлах.
foo.o: _static_function3
# Кроме того, вы можете сделать, чтобы строка с символом
# учитывалась только на указанной архитектуре; так что вам
# не придётся использовать раздельную линковку и вручную
# запускать lipo, как приходилось для переэкспортирования
# символов.
i386:symbol4

Переупорядочивание символов (а точнее, блоков кода и данных, отмеченных символами) осмысленно только если ничто не рассчитывает на возможность «провалиться» из содержимого одного символа напрямую в содержимое следующего. Такая техника часто используется во вручную написанном ассемблерном коде, но компиляторы предпочитают не полагаться но это, и чтобы явно обозначить, что код в файле не требует этой возможности, компиляторы обычно выдают специальную ассемблерную директиву .subsections_via_symbols, что отмечает генерируемый Mach-O-файл как содержащий символы, которые могут быть свободно переупорядочены, удалены, если не используются, и так далее.


Одно из мест, где сами Apple пользуются переупорядочиванием символов – реализация бесплатной конвертации в libdispatch. libdispatch реализует свою собственную объектную модель, «OS object», с помощью огромного количества макросов, разбросанных по нескольким файлам с исходным кодом. Эта модель в некоторой степени совместима с объектной моделью Objective-C, поэтому libdispatch также реализует бесплатную конвертацию (напоминающую бесплатную конвертацию из Core Foundation), возможность напрямую кастовать некоторые libdispatch-объекты в Objective-C-объекты и отправлять им сообщения, как и любым настоящим Objective-C-объектам. В частности, можно напрямую кастовать объекты типа dispatch_data_t к NSData * и использовать их с любыми API из Cocoa (но не в обратную сторону).


Бесплатная конвертация реализована с помощью чудовищного количества костылей, некоторые из которых требуют, чтобы символы Objective-C-классов и соответствующие OS object vtables были расположены в фиксированном порядке. В том числе, там есть макрос DISPATCH_OBJECT_TFB, проверяющий, происходит ли Objective-C объект из бесплатно конвертируемого libdispatch-класса, путём сравнения его isa с vtable-ами dispatch_object и object:


#define DISPATCH_OBJECT_TFB(f, o, ...) \
    if (slowpath((uintptr_t)((o)._os_obj->os_obj_isa) & 1) || \
            slowpath((Class)((o)._os_obj->os_obj_isa) < \
                    (Class)OS_OBJECT_VTABLE(dispatch_object)) || \
            slowpath((Class)((o)._os_obj->os_obj_isa) >= \
                    (Class)OS_OBJECT_VTABLE(object))) { \
        return f((o), ##__VA_ARGS__); \
    }

Вот файл порядка, который они используют, чтобы так упорядочить символы libdispatch.


Подмена символов


Обычный способ принудительно подменить реализацию функции (или содержимое любого символа) – использовать переменную окружения DYLD_INSERT_LIBRARIES, заставляющую dyld загрузить в процесс переданные Mach-O-файлы и дать им более высокий приоритет при разрешении имён символов. Конечно, этот более высокий приоритет не будет работать с бинарниками, использующими двухуровневое пространство имён, так что этот приём наиболее полезен в сочетании с DYLD_FORCE_FLAT_NAMESPACE.


Большинство задач, для которых применяется подмена реализации функции, включают в себя обёртывание функцией-заменителем оригинальной реализации. Чтобы вызвать оригинальную реализацию (а не саму обёртку), обёртка обычно использует вызов dlsym() с флагом RTLD_NEXT, как здесь:


int open(const char* path, int flags, mode_t mode) {
    printf("Вызвано open(%s)\n", path);
    // "Виртуальная символьная ссылка"
    if (strcmp(path, "foo") == 0) {
        path = "bar";
    }
    int (*original_open)(const char *, int, mode_t);
    original_open = dlsym(RTLD_NEXT, "open");
    return original_open(path, flags, mode);
}

В дополнение к этому, dyld предоставляет другой способ подмены символов, называемый dyld-подменой [dyld iterposing]. Если в любом из загруженных Mach-O-файлов есть секция __interpose, dyld воспримет её содержимое как пары указателей, каждая из которых – команда подменить реализацию символа.


С одной стороны, этот метод не требует переменных окружения – достаточно, чтобы в любой из библиотек была секция __interpose – поэтому его называют неявной подменой [implicit interposing]. С другой стороны, секция __interpose явно выражает желание подменить реализации символов (а не просто загрузить дополнительные библиотеки), поэтому dyld может обрабатывать этот случай более умно. В частности, dyld-подмена работает с двухуровневым пространством имён и не требует, чтобы имена исходного символа и символа-замены совпадали. Больше того, dyld достаточно умён, чтобы заставить это имя символа по-прежнему указывать на исходную реализацию, если использовать его внутри функции-заменителя (и всего этого Mach-O-файла):


static int my_open(const char* path, int flags, mode_t mode) {
    printf("Called open(%s)\n", path);
    // "Виртуальная символьная ссылка"
    if (strcmp(path, "foo") == 0) {
        path = "bar";
    }
    // Это вызывает оригинальную реализацию, несмотря на то
    // что open() в других местах теперь вызывает my_open().
    return open(path, flags, mode);
}

// помещаем пару указателей в секции __interpose
__attribute__ ((section ("__DATA,__interpose")))
static struct {
    void *replacement, *replacee;
} replace_pair = { my_open, open };

Обратите внимание, что указатель на заменяемый символ – как и любое обращение к символу из другого файла – на самом деле будет сохранено в Mach-O-файл как значение-заглушка и соответствующая ему запись в таблице перемещений [relocation table]. Запись о перемещении ссылается на символ по имени, и отсюда dyld и получает полное имя символа (с учётом двухуровневого пространства имён), к которому нужно применить подмену.


Альтернативно, существует приватная функция под названием dyld_dynamic_interpose, позволяющая произвольно подменять символы во время выполнения:


typedef struct {
    void *replacement, *replacee;
} replacement_tuple;

extern const struct mach_header __dso_handle;
extern void dyld_dynamic_interpose(const struct mach_header*,
                                   const replacement_tuple replacements[],
                                   size_t count);

void interpose() {
    replacement_tuple replace_pair = { my_open, open };
    dyld_dynamic_interpose(&__dso_handle, &replace_pair, 1);
}

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


DYLD_INSERT_LIBRARIES и dyld-подмена не настолько полезны при работе с кодом на Objective-C, как для C, частично из-за того, что сложно напрямую сослаться на реализацию Objective-C-метода (IMP), частично потому что Objective-C предоставляет свой собственный способ замены реализации методов, а именно method swizzlingisa swizzling).


Мы используем подмену символов в Darling в качестве детали реализации xtrace, нашего инструмента для трассировки эмулированных системных вызовов.


Программы для Darwin используют системные вызовы Darwin (которые бывают двух видов – системные вызовы BSD и так называемые Mach-ловушки [Mach traps]). Для этого они вызывают специальные функции из libsystem_kernel, где реализован ABI системных вызовов со стороны userspace. Под Darling, наша специальная версия libsystem_kernel переводит эти системные вызовы Darwin в обычные системные вызовы Linux и обращения к Darling-Mach, нашему модулю ядра Linux, эмулирующему Mach со стороны ядра.


strace, популярный инструмент для трассировки процессов под Linux, может отображать системные вызовы, совершаемые Linux-процессом; использование strace с программой для Darwin, запущенной под Darling, выдаёт трассу системных вызовов Linux, совершаемых нашим кодом эмуляции системных вызовов Darwin (а также системных вызовов Linux, совершаемых любыми загруженными ELF-библиотеками напрямую). Это очень полезно, но системные вызовы Darwin и Linux не всегда прямо соответствуют друг другу, и часто видеть, какие системные вызовы Darwin делает программа – до того, как они проходят через слой эмуляции – может оказаться предпочтительнее.


Для этого у нас есть свой собственный трассировщик, xtrace. В отличие от strace, который не требует кооперации со стороны трассируемого процесса за счёт того, что использует ptrace() API, xtrace нужно подцепиться к слою эмуляции системных вызов внутри этого процесса. Для этого он использует DYLD_INSERT_LIBRARIES=/usr/lib/darling/libxtrace.dylib, подменяя несколько функций-трамплинов [trampoline functions] из слоя эмуляции системных вызовов на версии, которые логируют совершаемый системный вызов и его результат. Хотя xtrace не настолько продвинут, как strace, в плане форматирования аргументов и возвращаемых значений, он отображает достаточно базовой информации для того, чтобы быть полезным:


Darling [~]$ xtrace arch
<...обрезано...>
[223] mach_timebase_info_trap (...)
[223] mach_timebase_info_trap () -> KERN_SUCCESS
[223] issetugid (...)
[223] issetugid () -> 0
[223] host_self_trap ()
[223] host_self_trap () -> port right 2563
[223] mach_msg_trap (...)
[223] mach_msg_trap () -> KERN_SUCCESS
[223] _kernelrpc_mach_port_deallocate_trap (task=2563, name=-6)
[223] _kernelrpc_mach_port_deallocate_trap () -> KERN_SUCCESS
[223] ioctl (...)
[223] ioctl () -> 0
[223] fstat64 (...)
[223] fstat64 () -> 0
[223] ioctl (...)
[223] ioctl () -> 0
[223] write_nocancel (...)
i386
[223] write_nocancel () -> 5
[223] exit (...)

Здесь вы можете видеть, как процесс использует несколько системных вызовов BSD и Mach. Часть из них, такие как write() и exit(), просто отображаются на свои Linux-аналоги, а для других нужна более сложная трансляция. Например, все Mach-ловушки переводятся в ioctl-ы на устройстве /dev/mach, реализованном в нашем модуле ядра; а вызовы BSD-шного ioctl(), которые делаются stdio для определения того, к файлам какого типа относятся stdin и stdout (в этом случае это tty) преобразуются в исследование результата вызова readlink() для файлов в /proc/self/fd/.




Я не могу рассказать о каждой из множества особенностей Mach-O, не рискуя сделать этот пост таким же длинным, как весь исходный код dyld. Кратко опишу ещё несколько из них:


  • Если вы пишете плагин, который будет загружаться приложением во время выполнения, вам может потребоваться прилинковать dylib, содержащую плагин, к исполняемому файлу этого приложения. ld позволяет сделать это путём использования опции -bundle_loader.
  • Кроме установочного имени, команды LC_LOAD_DYLIB, LC_REEXPORT_DYLIB и LC_DYLIB_ID содержат по паре чисел, так называемые совместимую и текущую версии [compatibility version, current version] библиотеки, где совместимая версия – самая ранняя из версий, с которой совместима текущая. При линковке библиотеки можно задать её текущую и совместимую версии с помощью опций ld -current_version и -compatibility_version, соответственно. Если во время выполнения dyld обнаружит, что текущая версия найденной копии библиотеки меньше, чем требуемая совместимая версия, он откажется загружать эту библиотеку.
  • Независимо от совместимой и текущей версий, файлы Mach-O могут также опционально объявлять версию исходного кода [source version]. Для этого используется специальная команда, LC_SOURCE_VERSION. Саму версию можно установить с помощью опции ld -source_version, а повлиять на то, будет ли она включена в результирующий Mach-O-файл – с помощью опций -add_source_version и -no_source_version.
  • Внедрение содержимого файла Info.plist в секцию __info_plist Mach-O-файла позволяет вам подписывать [codesign] программы, состоящие из одного исполняемого файла и не имеющие отдельного файла Info.plist. Это реализовано через ad-hoc проверку в Security.framework, что означает, что это не поддерживается обычными CFBundle / NSBundle API, так что использовать это для создания приложений, состоящих из одного исполняемого файла, не получится.

Наконец, стоит отметить, что в дополнение ко всем вышеупомянутым трюкам, ld и dyld также содержат разнообразные хаки, заставляющие их обрабатывать «системные библиотеки», в особенности libSystem, слегка по-другому. Они активируются путём сравнения установочного имени библиотеки с захардкоженными префиксами вроде /usr/lib/.

  • +14
  • 1,6k
  • 6
Поделиться публикацией
Ой, у вас баннер убежал!

Ну. И что?
Реклама
Комментарии 6
    +1
    Задумка и цель Darling сделать аналог Wine, только для запуска macOS программ? Звучит здорово! Удачи вам, парни.
      0

      Именно! Спасибо, стараемся :)

      0
      Проект у вас просто замечательный. Слежу за ним уже давно…
      А не «посещала» вас идея «запилить» аналог ReactOS на основе форка Darwin?
        0

        Посещала!


        В принципе, похожие проекты уже есть, например, PureDarwin и GNU-Darwin, хотя у них, вроде как, нет Cocoa и всего высокоуровнего стека для macOS-приложений. Впрочем они могли бы, как мы, использовать реализации из проектов The Cocotron или GNUstep.


        Проблема с созданием полноценной системы в том, что для неё требуется сильно больше, чем ядро и bash, нужны разнообразные драйвера, сервисы, графическая подсистема и т.п. В Linux всё это уже есть — и драйвера под практически любое оборудование, и системные сервисы вроде тех же udev, PulseAudio/PipeWire, wpa_supplicant и NetworkManager, и крутая современная графическая подсистема (DRM, KMS, Mesa, Vulkan, Wayland). В Darling мы стараемся максимально облегчить сам контейнер и использовать уже существующие на хосте сервисы, библиотеки и т.д. Для потенциальной DarlingOS пришлось бы или всё это перереализовывать, или пытаться портировать с Linux, или отказаться от большинства того, что отличает современную операционную систему от Unix 70-х годов.


        Ну и потом, основная мотивация разрабатывать ReactOS вместо того, чтобы просто использовать Linux + Wine — jeditobe, поправьте меня, если я не прав :) — это потенциальная возможность использовать драйвера для Windows, которые часто всё-таки находятся в лучшем состоянии, чем их аналоги для Linux. А вот у macOS и iOS едва ли есть в этом плане преимущество перед Linux, поэтому вариант Linux + Darling подойдёт лучше всего.

        0
        Создалось впечатление, что запуск GUI-прграмм не за горами, правильно создалось?
          0

          Официально мы про это пока ничего не объявляли, но создалось правильно, следите за новостями ;)

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое