Динамический загрузчик программ

Функции динамического загрузчика (компоновщика) стандартной библиотеки, работа с разделяемыми библиотеками, статическая и динамическая компоновка

Разделяемые объекты
Статически линкуемые библиотеки
Динамически линкуемые библиотеки
Добавление кода в процессе работы программы
Способы использования разделяемых объектов
ELF — формат исполняемых файлов
ELF файл без COFF
От программы к процессу
Динамический загрузчик
Загрузка разделяемой библиотеки в процессе исполнения
Разрешение имен идентификаторов

Разделяемые объекты

Обычно в системе одновременно выполняется множество программ. Работа каждой программы зависит от множества функций, код которых не является частью исполняемого файла, а входят в состав библиотек. Так, например, функции printf(), malloc(), write() и многие другие входят в состав системной библиотеки.

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


Note: В данном случае речь идет о сегменте кода библиотеки, а не о сегменте данных, которые уникальны в каждом процессе.

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

Статически линкуемые библиотеки

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

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

Статически линкуемые программы компонуются в единый бинарный файл с библиотеками, представленными в виде архивов объектных файлов (.o). Такие архивы обычно имеют расширение .a и формируются инструментальной утилитой ar. Примером такой библиотеки является системная библиотека – один из вариантов которой доступен как файл libc.a.

Динамически линкуемые библиотеки

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

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

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

Для того, чтобы сообщить инструментальным средствам о том, какой тип линковки следует использовать — статический или динамический — применяется соответствующая опция командной строки утилиты компилятора gcc.

Добавление кода в процессе работы программы

При таком подходе вызываемые из программы функции будут определены только на этапе исполнения. Это предоставляет дополнительные возможности.

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

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

Способы использования разделяемых объектов

В ЗОСРВ «Нейтрино» используется двоичный формат исполняемых файлов, именуемый ELF (от англ. Executable and Linkable Format). Для пояснения принципов динамической линковки стоит рассмотреть его чуть подробнее.

ELF — формат исполняемых файлов

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

4_1.png
Рисунок 1. Формат объектного файла: представление при линковке и исполнении

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

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

ELF файл без COFF

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

ЗОСРВ «Нейтрино» не использует данный подход, взяв за основу спецификацию формата ELF. В результате загрузчик ориентируется нв представление исполнения программы, а не линковки. Благодаря этому работа загрузчика значительно упрощается, поскольку все, что ему нужно сделать — это скопировать в память загружаемые сегменты (обычно их только два: кода и данных) программы или требуемых динамических библиотек. Таким образом, создание процессов и загрузка библиотек происходит существенно быстрее.

От программы к процессу

На следующей иллюстрации представлено типичное адресное пространство процесса. Загружаемые сегменты процесса размещаются по базовому адресу процесса. Стек основного потока расположен ниже и растет вниз. Стеки остальных потоков располагаются под ним. В заключительной части каждого стека располагается защитная страница (англ. guard page), которая используется для детектирования переполнения стека.

4_2.png
Рисунок 2. Схема распределения памяти в процессе (архитектура x86)

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

При создании нового процесса менеджер процессов сначала загружается в память сегменты исполняемого файла. Затем выполняется декодирование ELF-заголовка программы и, если в нем отмечена зависимость от разделяемых библиотек, извлекается имя динамического интерпретатора. Динамический интерпретатор указывает на разделяемую библиотеку, которая содержит код динамического загрузчика (англ. runtime linker). Менеджер процессов загружает данную библиотеку в память и передает ей управление для рекурсивной загрузки всех динамических библиотек, от которых зависит программа.

Динамический загрузчик

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

Он выполняет следующие действия при загрузке разделяемой библиотеки (файла с суффиксом .so):

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

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

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

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

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

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

Загрузка разделяемой библиотеки в процессе исполнения

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


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

С помощью вызова dladdr() можно определить символ, который ассоциирован с указанным адресом.

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

Разрешение имен идентификаторов

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

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

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

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

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

  3. Глобальный список разделяемых объектов.

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

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




Предыдущий раздел: перейти