7. Динамическая компоновка


Разделяемые объекты
В типичной системе одновременно выполняется множество программ. Работа каждой программы зависит от множества функций, некоторые из которых входят в состав "стандартной" Си-библиотеки (например, printf(), malloc(), write() и т. д.).

Если каждая программа использует стандартную Си-библиотеку, значит каждая программа, как правило, содержит свою особую копию данной библиотеки. К сожалению, это ведет к нерациональному использованию ресурсов. Поскольку библиотека Си является общей, разумнее было бы сделать так, чтобы каждая программа ссылалась на общий экземпляр этой библиотеки, а не содержала ее копию. Такой подход имеет несколько преимуществ, и не последним из них является значительная экономия общесистемных ресурсов памяти.
Статическая компоновка
Термин "статически скомпонованный" (statically linked) означает, что программа и некоторая библиотека были объединены с помощью компоновщика (linker) во время процесса компоновки. Таким образом, связь между программой и библиотекой является фиксированной и устанавливается во время процесса компоновки, т. е. до того, как программа будет работать. Кроме всего прочего, это также означает, можно изменить данную связь иначе, как посредством перекомпоновки программы с новой версией библиотеки.

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

Статически скомпонованные программы компонуются с архивами объектов (библиотеками), которые обычно имеют расширение a. Примером такого набора объектов является стандартная Си-библиотека libc.a.
Динамическая компоновка
Термин "динамически скомпонованный" (dynamically linked) означает, что программа и некоторая библиотека не были объединены с помощью компоновщика во время процесса компоновки. Вместо этого, компоновщик помещает информацию в исполняемый файл, который, в свою очередь, сообщает загрузчику, в каком разделяемом объектном модуле расположен код и какой динамический компоновщик (runtime linker) должен использоваться для поиска и компоновки ссылок. Это означает, что связь между программой и разделяемым объектом устанавливается во время выполнения программы, а именно, в самом начале выполнения производится поиск и компоновка необходимых разделяемых объектов.

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

Динамически скомпонованные программы компонуются с разделяемым объектами с расширением so. Примером такого объекта является разделяемая стандартная Си-библиотека libc.so.

Для того, чтобы сообщить комплекту инструментов о том, какой тип компоновки применяется — статический или динамический — используется соответствующая опция командной строки утилиты qcc. Эта опция затем определяет используемое расширение (a или so).
Добавление кода в процессе работы программы
При таком подходе вызываемые из программы функции будут определены только на этапе исполнения. Это предоставляет дополнительные возможности.

Рассмотрим пример работы драйвера диска. Драйвер запускается, тестирует оборудование и обнаруживает жёсткий диск. Затем драйвер динамически загружает модуль io-blk, предназначенный для обработки дисковых блоков, т.к. было обнаружено блок-ориентированное устройство. После того как драйвер получает доступ к диску на блочном уровне, он обнаруживает на диске два раздела: раздел DOS и раздел QNX4. Чтобы не увеличивать размер драйвера жёсткого диска, в него вообще не включаются драйверы файловых систем. Во время работы системы драйвер может обнаружить эти два раздела (DOS и QNX4) и только после этого загрузить соответствующие модули файловых систем fs-dos.so и fs-qnx4.so.

Таким образом, откладывая вызов необходимых функций на более поздние этапы, увеличивается гибкость и компактность драйвера жёсткого диска.
Как используются разделяемые объекты
Для того чтобы понять, как программа использует разделяемые объекты, необходимо сначала рассмотреть формат исполняемого модуля, а затем последовательность тех стадий, через которые программа проходит при запуске.
Формат ELF
В ОС QNX Neutrino используется так называемый двоичный формат исполняемых и компонуемых модулей (Executable and Linkable Format, ELF), который в настоящее время принят в системах SVR4 Unix. Формат ELF не только упрощает создание разделяемых библиотек, но также расширяет возможности динамической загрузки модулей во время работы программы.

На рис. 7.1 показан ELF-файл в двух представлениях: представление компоновки и представление исполнения. Представление компоновки, используемое в процессе компоновки программы или библиотеки, касается секций (sections) внутри объектного файла. Секции содержат большую часть информации этого файла: данные, инструкции, настроечная информация, идентификаторы, отладочная информация и т. д. Представление исполнения, используемое при выполнении программы, касается сегментов (segments).

В процессе компоновки программа или библиотека строится посредством слияния секций, имеющих одинаковые атрибуты, и преобразования их в сегменты. Как правило, все секции, содержащие данные, которые предназначены для исполнения или "только для чтения", компонуются в один сегмент text, а данные и BSS4 компонуются в сегмент data. Эти сегменты называются загрузочными сегментами (load segments), потому что они должны быть загружены в память при создании процесса. Другие секции, как, например, информация об идентификаторах и отладочная информация, объединяются в т. н. незагружаемые сегменты (nonload segments).


Рис. 7.1. Формат объектного файла: представление компоновки и представление исполнения

ELF без COFF
Большинство реализаций ELF-загрузчиков основаны на COFF-загрузчиках (Common Object File Format, общий формат объектных файлов) и во время загрузки используют ELF-объекты в представлении компоновки. Это неэффективно, так как программный загрузчик должен загрузить исполняемый модуль посредством секций. Программа, как правило, содержит большое количество секций, каждую из которых необходимо локализовать в программе и загрузить в память.

Однако в ОС QNX Neutrino метод COFF вообще не используется для загрузки секций. При разработке реализации формата ELF для QNX Neutrino за основу была взята именно спецификация ELF. В результате ELF-загрузчик в ОС QNX Neutrino использует представление исполнения программы, а не представление компоновки. Благодаря этому работа загрузчика значительно упрощается, поскольку все, что ему нужно сделать, — это скопировать в память загрузочные сегменты (обычно их только два) программы или библиотеки. Таким образом, создание процессов и загрузка библиотек происходит намного быстрее.
Схема распределения памяти для процесса
На рис. 7.2 показана карта памяти (memory layout) типичного процесса. Загрузочные сегменты процесса (на схеме обозначены как "Текст" и "Данные") загружаются в базовый адрес процесса. Основной стек расположен сразу под ним. Дополнительные потоки при создании получают свои собственные стеки, которые помещаются под основным стеком. Все стеки разделены между собой посредством защитных страниц (guard page), которые служат для того, чтобы следить за переполнением стека.

Рис. 7.2. Схема распределения памяти для процесса (на архитектурах x86)

Посредине адресного пространства процесса зарезервирована большая область для разделяемых объектов. Разделяемые библиотеки располагаются в верхней области адресного пространства.

При создании нового процесса администратор процессов сначала отображает два сегмента исполняемого модуля в памяти. Затем он выполняет декодирование ELF-заголовка программы. Если заголовок программы указывает на то, что исполняемый модуль связан с разделяемой библиотекой, администратор процессов извлекает из этого заголовка имя динамического интерпретатора (dynamic interpreter). Динамический интерпретатор определяет разделяемую библиотеку, которая содержит код динамического компоновщика (runtime linker). Администратор процессов загружает данную разделяемую библиотеку в память и затем передает управление динамическому компоновщику в этой разделяемой библиотеке.
Динамический компоновщик
Динамический компоновщик вызывается при запуске программы, которая связана с разделяемым объектом, или когда программа делает запрос на динамическую загрузку разделяемого объекта. Динамический компоновщик содержится в системной библиотеке Си. Он выполняет несколько следующих функций при загрузке разделяемой библиотеки (файла с суффиксом .so):

    1. Если запрашиваемая разделяемая библиотека еще не загружена в память, ее загружает динамический компоновщик:

    а) Если динамическая секция исполняемого модуля содержит тег DT_RPATH, тогда поиск выполняется по пути, указанному в этом теге.

    б) Если разделяемая библиотека не найдена, то динамический компоновщик ищет ее в каталогах, заданных переменной LD_LIBRARY_PATH, при условии, что программа не имеет атрибут setuid.

    в) Если разделяемая библиотека все равно не найдена, тогда динамический компоновщик определяет путь поиска библиотеки по умолчанию, который задан модулю procnto посредством переменной окружения LD_LIBRARY_PATH (т.е. в строке конфигурации CS_LIBPATH). Если данная переменная окружения не определена, тогда путь поиска библиотеки по умолчанию задается как путь к образной файловой системе.

    2. После обнаружения запрошенной разделяемой библиотеки, она загружается в память. Для разделяемых библиотек формата ELF эта операция очень эффективна, так как динамическому компоновщику нужно всего лишь два раза использовать вызов mmap() для того, чтобы отобразить два загружаемых сегмента в памяти.

    3. Затем разделяемая библиотека добавляется во внутренний список всех библиотек, которые были загружены данным процессом. Список составляется динамическим компоновщиком.

    4. Далее динамический компоновщик декодирует динамическую секцию разделяемого объекта.

Эта динамическая секция предоставляет компоновщику информацию о других библиотеках, с которыми данная библиотека связана. Она также содержит информацию о перераспределениях памяти, которые должны быть выполнены, а также о внешних идентификаторах, которые должны быть разрешены. Динамический компоновщик в первую очередь загружает другие запрошенные библиотеки (которые в свою очередь могут ссылаться на другие разделяемые библиотеки). Затем он обрабатывает перераспределения памяти, необходимые для каждой библиотеки. Некоторые из этих перераспределений могут быть локальными по отношению к библиотеке, а другие требуют того, чтобы динамический компоновщик выполнил разрешение глобального идентификатора. В последнем случае динамический компоновщик отбирает библиотеки, необходимые для этого идентификатора, по списку библиотек. В ELF-файлах для поиска идентификаторов используются хеш-таблицы (hash tables), что значительно ускоряет работу. Порядок, в котором выполняется поиск идентификаторов в библиотеках, тоже имеет большое значение, о чем будет сказано далее в разделе "Разрешение имен идентификаторов".

После того как все перераспределения памяти выполнены, вызываются все функции инициализации, которые были зарегистрированы в секции инициализации разделяемой библиотеки. Эта операция также применяется в некоторых реализациях С++ для вызова глобальных конструкторов.
Загрузка разделяемой библиотеки во время работы программы
Процесс может динамически загрузить разделяемую библиотеку с помощью вызова dlopen(), который дает динамическому компоновщику инструкцию загрузить указанную библиотеку. После загрузки необходимой библиотеки программа может вызвать любую функцию, содержащуюся в данной библиотеке, с помощью вызова dlsym(), который позволяет определить адрес нужной функции.

Замечание

Следует помнить, что разделяемые библиотеки доступны только тем процессам, которые динамически скомпонованы.

С помощью вызова dladdr() программа также может определить идентификатор, который связан с данным адресом. И, наконец, когда процессу больше не требуется данная разделяемая библиотека, он может сделать вызов dlclose() для того, чтобы выгрузить ее из памяти.
Разрешение имен идентификаторов
Когда динамический компоновщик загружает разделяемую библиотеку, должно быть выполнено разрешение идентификаторов, содержащихся в этой библиотеке. Здесь важны порядок и области видимости (scope). Если разделяемая библиотека вызывает функцию, которая имеет то же имя в других библиотеках, загруженных программой, то важное значение имеет порядок, в котором библиотеки обрабатываются для поиска данного идентификатора. Поэтому в ОС QNX Neutrino определено несколько опций, применяемых при загрузке библиотек.

Все объекты (исполняемые модули и библиотеки), которые имеют глобальный статус, заносятся во внутренний список (т. н. глобальный список — global list). Любой глобальный объект по умолчанию предоставляет все свои идентификаторы любой разделяемой библиотеке, которая загружается. Глобальный список изначально включает в себя исполняемый модуль и все библиотеки, которые должны быть загружены при запуске программы.

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

    1. Разделяемая библиотека.

    2. Список библиотек, заданный переменной окружения LD_PRELOAD. Эта переменная окружения может использоваться для добавления или изменения функциональности при запуске программы. Для двоичных модулей ELF с установленным битом setuid или setgid будут загружаться только библиотеки, расположенные в стандартных каталогах поиска и так же имеющие установленный бит setuid или setgid.

    3. Глобальный список.

    4. Все зависимые объекты, на которые ссылается разделяемая библиотека (т. е. любые другие библиотеки, с которыми данная библиотека связана).

При загрузке разделяемой библиотеки с помощью вызова dlopen() интерпретация области видимости идентификатора динамическим компоновщиком может быть изменена двумя способами.
4 Block Started by Symbol — блок неинициализированных данных, генерируемый Unix-компоновщиками (см. http://computing-dictionary.thefreedictionary.com/Block+Started+by+Symbol). — Прим. научн. ред.