Функции динамического загрузчика (компоновщика) стандартной библиотеки, работа с разделяемыми библиотеками, статическая и динамическая компоновка
Обычно в системе одновременно выполняется множество программ. Работа каждой программы зависит от множества функций, код которых не является частью исполняемого файла, а входят в состав библиотек. Так, например, функции printf(), malloc(), write() и многие другие входят в состав системной библиотеки.
Эту библиотеку используют все программы в системе (включая микроядро операционной системы), а следовательно, каждая из них должна иметь свою копию данной библиотеки. Такой прямолинейный подход ведет к необоснованному расходованию ресурсов и на практике не применяется. Поскольку системная библиотека является общей, каждая использующая ее программа оперирует лишь ссылкой на общий ее экземпляр.
В данном случае речь идет о сегменте кода библиотеки, а не о сегменте данных, которые уникальны в каждом процессе. |
Такой подход имеет ряд преимуществ, и не последним из них является значительная экономия общих системных ресурсов (например, памяти).
Термин "статически слинкованный" означает, что программа и библиотеки, от которых она зависит, были объединены в единый исполняемый файл во время компиляции и последующей линковки. Таким образом, связь между программой и библиотеками является фиксированной и установлена до того, как программа начнет исполняться. Кроме того, изменить данную связь невозможно без повторного выполнения компиляции/линковки.
Статическая компоновка имеет смысл в тех случаях, когда нет уверенности в том, что при исполнении будет доступна правильная версия требуемой библиотеки, либо в тех случаях, когда тестируется новая версия библиотеки, не готовая в установке в систему для общего доступа.
Статически линкуемые программы компонуются в единый бинарный файл с библиотеками, представленными в виде архивов объектных файлов (.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-файл в двух представлениях: при линковке и при исполнении. В первом случае линкер имеет дело с секциями объектного файла. Секции содержат большую часть информации этого файла: данные, инструкции, сведения о релокациях (о перемещениях символов в памяти), символы, отладочную информация и т.д. При исполнении динамический загрузчик имеет дело с сегментами (кода, данных, ...).
В процессе линковки программа или библиотека строится посредством слияния секций, имеющих одинаковые атрибуты, и преобразования их в сегменты. Так, например, все секции, содержащие исполняемые данные и имеющие атрибут "только для чтения" составляют сегмент кода (text сегмент), а данные и BSS формируют сегмент данных (data). Эти сегменты называются загружаемыми, поскольку они должны быть загружены при создании процесса и загрузке программы в память. Все остальные секции, включая, символьную и отладочную информацию, объединяются в незагружаемые сегменты.
Большинство реализаций загрузчиков ELF-файлов происходят от COFF-загрузчиков (от англ. Common Object File Format), которые используют представление линковки (см. предыдущий параграф) во время загрузки. Это неэффективно, поскольку загрузчик программ должен загрузить исполняемый модуль посекционно. Типичная программа может содержать большое число секций, каждую из которых необходимо обнаружить и загрузить в память по отдельности.
ЗОСРВ «Нейтрино» не использует данный подход, взяв за основу спецификацию формата ELF. В результате загрузчик ориентируется нв представление исполнения программы, а не линковки. Благодаря этому работа загрузчика значительно упрощается, поскольку все, что ему нужно сделать — это скопировать в память загружаемые сегменты (обычно их только два: кода и данных) программы или требуемых динамических библиотек. Таким образом, создание процессов и загрузка библиотек происходит существенно быстрее.
На следующей иллюстрации представлено типичное адресное пространство процесса. Загружаемые сегменты процесса размещаются по базовому адресу процесса. Стек основного потока расположен ниже и растет вниз. Стеки остальных потоков располагаются под ним. В заключительной части каждого стека располагается защитная страница (англ. guard page), которая используется для детектирования переполнения стека.
Ближе к верхней части адресного пространства зарезервирована область адресов для разделяемых объектов. Они располагаются в верхней части этой области и размещаются друг под другом.
При создании нового процесса менеджер процессов сначала загружается в память сегменты исполняемого файла. Затем выполняется декодирование ELF-заголовка программы и, если в нем отмечена зависимость от разделяемых библиотек, извлекается имя динамического интерпретатора. Динамический интерпретатор указывает на разделяемую библиотеку, которая содержит код динамического загрузчика (англ. runtime linker). Менеджер процессов загружает данную библиотеку в память и передает ей управление для рекурсивной загрузки всех динамических библиотек, от которых зависит программа.
Динамический компоновщик вызывается при запуске программы, которая использует разделяемые объекты, или когда она пытается выполнить загрузку нового разделяемого объекта. Динамический загрузчик является частью системной библиотеки.
Он выполняет следующие действия при загрузке разделяемой библиотеки (файла с суффиксом .so
):
/
), она загружается по указанному пути. Если библиотека в указанном месте отсутствует, дальнейший поиск не выполняется.
DT_RPATH
, тогда поиск выполняется по пути, указанному в нем.
CS_LIBPATH
). Если данная переменная не определена, путь поиска библиотеки определяется как путь к файловой системе образа – /proc/boot
.
Эта динамическая секция предоставляет компоновщику информацию о разделяемых объектах, от которых зависит данная библиотека. Она также содержит информацию о релокациях памяти, которые должны быть выполнены, а также о внешних символах, которые должны быть разрешены (обнаружены). В первую очередь загрузчик рекурсивно обеспечивает доступность в адресном пространстве процесса запрошенных библиотек. Затем он обрабатывает релокации памяти, необходимые каждой отдельно взятой библиотеке. Некоторые из них могут быть локальными по отношению к библиотеке, другие требуют того, чтобы загрузчик выполнил разрешение глобального символа. В последнем случае будет производиться поиск символа по всему списку библиотек. В ELF-файлах для такого поиска используются хеш-таблицы, что значительно ускоряет процедуру. Порядок поиск символов в библиотеках имеет большое значение и описан в разделе "Разрешение имен идентификаторов".
После выполнений всех релокаций символов (см. также RTLD_LAZYLOAD), вызываются функции инициализации, которые были зарегистрированы в секции инициализации разделяемой библиотеки. Этот подход также применяется в некоторых реализациях С++ для вызова глобальных конструкторов.
Процесс может динамически загрузить разделяемую библиотеку с помощью вызова dlopen(), который позволяет динамическому загрузчику обеспечить доступность в процессе указанной библиотеки. После загрузки библиотеки процесс может вызвать любую ее публичную функцию (или обратиться к глобальной переменной), найдя в ней соответствующий символ и получив его адрес с помощью вызова dlsym().
Следует помнить, что разделяемые библиотеки доступны только тем процессам, которые динамически слинкованы с системной библиотекой. |
С помощью вызова dladdr() можно определить символ, который ассоциирован с указанным адресом.
Когда процессу больше не требуется данная разделяемая библиотека, ее можно выгрузить из памяти с помощью dlclose().
При загрузке разделяемой библиотеки, символы в ней должны быть успешно разрешены. Здесь важны порядок обхода библиотек и области видимости символов. Если разделяемая библиотека вызывает функцию, которая имеет аналог (символ с таким же именем) в других загруженных библиотеках, решающим фактором является порядок обхода библиотек. Для управления этим процессом загрузчик имеет несколько параметров. Рассмотрим их подробнее.
Все объекты (исполняемый файл программы и динамические библиотеки), которые имеют глобальную область видимости, заносятся во внутренний список (глобальный список). Любой глобальный объект по умолчанию предоставляет доступ ко всем своим символам любой разделяемой библиотеке, которая будет загружена. Глобальный список изначально включает в себя исполняемый файл программы и все библиотеки, от которых она явно зависит.
При загрузке с помощью dlopen() новой библиотеки по умолчанию применяется следующий порядок разрешения символов, содержащихся в этой библиотеке:
При загрузке разделяемой библиотеки с помощью dlopen() поведение динамического загрузчика может быть изменена следующими способами:
RTLD_GLOBAL
. В результате все символы данной библиотеки станут доступными для всех библиотек, которые впоследствии будут загружены.
RTLD_GROUP
, тогда поиск символов будет осуществляться только в тех объектах, на которые библиотека ссылается непосредственно. Если передан флаг RTLD_WORLD
, тогда поиск осуществляется только в объектах, перечисленных в глобальном списке.
Предыдущий раздел: перейти