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

Ранее мы упоминали разделяемые библиотеки как одно из преимуществ страничных и сегментных диспетчеров памяти перед базовыми и банковыми. При базовой адресации образ каждого процесса должен занимать непрерывные области как в физическом, так и в логическом адресном пространстве. В этих условиях реализовать разделяемую библиотеку невозможно. Но и при использовании страничной адресации не все так просто.
Использование разделяемых библиотек и/или DLL (в данном случае разница между ними не принципиальна) предполагает ту или иную форму сборки в момент загрузки: исполняемый модуль имеет неразрешенные адресные ссылки и имена библиотек, которые ему нужны. При загрузке эти библиотеки подгружаются и ссылки разрешаются. Проблема здесь в том, что при Подгрузке библиотеки ее нужно переместить, перенастроив абсолютные адресные ссылки в ее коде и данных (см. главу 3). Если в разных процессах библиотека будет настроена на разные адреса, она уже не будет разделяемой (рис. 5.14)! Если разделяемые библиотеки могут иметь неразрешенные ссылки на другие библиотеки, проблема только усугубляется — к перемещаемым ссылкам добавляются еще и внешние.

Рис. 5.14. Конфликтующие адреса отображения DLL

В старых системах семейства Unix, использовавших абсолютные загружаемые модули формата a.out, разделяемые библиотеки также поставлялись в формате абсолютных модулей, настроенных на фиксированные адреса. Каждая библиотека была настроена на СБОЙ адрес. Поставщик новых библиотек должен был согласовать этот адрес с разработчиками системы. Это было весьма непрактично, поэтому разделяемых библиотек было очень мало (особенно если не считать те, которые входили в поставку ОС).
Более приемлемое решение этой проблемы реализовано в OS/2 2.x и Win32 (обе эти архитектуры являются развитием систем с единым адресным пространством). Идея состоит в том, чтобы выделить область адресов под загрузку DLL и отображать эту область в адресные пространства всех процессов. Таким образом, все DLL, загруженные в системе, видны всем (рис. 5.15).
Очевидным недостатком такого решения (как, впрочем, и предыдущего) является неэффективное использование адресного пространства: при cколь-нибудь сложной смеси загруженных программ большей части процессов большинство библиотек будет просто не нужны. В те времена, когда эта архитектура разрабатывалась, это еще не казалось серьезной трудностью, но сейчас, когда многим приложениям становится тесно в 4 Гбайт и серверы с таким объемом оперативной памяти уже не редкость, это действительно может стать проблемой.

Рис. 5.15. Загрузка DLL в OS/2 и Win32

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

Разделяемые библиотеки формата ELF
Исполняемые модули формата ELF бывают двух типов: статические — полностью самодостаточные, не использующие разделяемых объектов, и динамические — содержащие ссылки на разделяемые объекты и неразрешенные символы. И статические, и динамические модули являются абсолютными. При создании образа процесса система начинает с того, что отображает старчески собранный исполняемый объект в адресное пространство. Статический модуль не нуждается ни в какой дополнительной настройке и может начать исполнение сразу после этого.
Для динамического же загрузочного модуля система загружает так называемый интерпретатор, или редактор связей времени исполнения (run-time linker), no умолчанию ld.so.1. Он исполняется в контексте процесса и осуществляет подгрузку разделяемых объектов и связывание их с кодом основного модуля и друге другом.
При подгрузке разделяемый объект также отображается в адресное пространство формируемого процесса. Отображается он не на какой-либо фиксированный адрес, а как получится, с одним лишь ограничением: сегменты объекта будут выровнены на границу страницы. Не гарантируется даже, что адреса сегментов будут одинаковы при последовательных запусках одной и той же программы.
Документ [HOWTO Library] без обиняков утверждает, что в разделяемых объектах можно использовать только код, компилированный с ключом -? рте. Документ [docs.sun.com 816-0559-10] менее категоричен:
"Если разделяемый объект строится из кода, который не является позиционно-независимым, текстовый сегмент скорее всего потребует большое количество перемещений во время исполнения. Хотя редактор связей и способен их обработать, возникающие вследствие этого накладные расходы могут вести к серьезному снижению производительности".
Как уже говорилось в разд. 3.5, используемый в разделяемых объектах код не является истинно позиционно-независимым: он содержит перемещаемые и даже настраиваемые адресные ссылки, такие, как статически инициализованные указатели и ссылки на процедуры других модулей. Но все эти ссылки размещены в сегменте данных. Используемые непосредственно в коде ссылки собраны в две таблицы, GOT (Global Offset Table, Глобальная таблица смещений) и PL Т (Procedure Linkage Table, Таблица процедурного связывания) (рис. 5.16). Каждый разделяемый модуль имеет свои собственные таблицы. Порожденный компилятором код определяет адреса этих таблиц, зная их смещение в разделяемом объекте относительно точки входа функции (см. примеры 3.7 и 5.1)

Рис. 5.16. Global Offset Table (Глобальная таблица смещений) и Procedure Linkage Table (Таблица процедурного связывания)

Пример 5.1. Типичный пролог функции, предназначенной для использования в разделяемом объекте

• text
•align 2,0x90
•globl _strerror
_strerror:
pushl %ebp ; Стандартный пролог функции
movl %esp,%ebp
pushl %ebx
call L4
popl %ebx ; Загрузка текущего адреса в регистр ЕВХ
acldl $_GLOBAL_OFFSET_TABLE_+ [ . -L4 ] , %ebx

Сегмент кода отображается с разделением его между всеми процессами, использующими объект (конечно, при условии, что он был компилирован с правильными ключами и не содержит перемещаемых адресов).
Напротив, сегмент данных и таблицы GOT и PLT создаются в каждом образе заново и, если это необходимо, адресные ссылки в них подвергаются перемещению. По мере разрешения внешних ссылок, интерпретатор заполняет р|т объекта ссылками на символы, определенные в других объектах (подобный стиль работы с внешними ссылками широко распространен в байт-кодах интерпретируемых языков — см. разд. Сборка в момент звгрузки).
Сегмент данных разделяемого объекта, таким образом, соответствует тому что в OS/2 и Win32 называется приватным сегментом данных DLL: каждая задача, использующая объект, имеет свою копию этого сегмента. Аналога глобальному сегменту данных разделяемые библиотеки ELF не имеют — et%; это необходимо, код библиотеки может создать собственный сегмент разделяемой памяти, но в нем невозможно иметь статически инициализованные данные и для него никто не гарантирует отображения на одни и те же адреса разных процессов, поэтому в нем невозможно хранить указатели.
По умолчанию, интерпретатор осуществляет отложенное редактирование связей: если сегмент данных он полностью настраивает до передачи управления пользовательскому коду, то записи в PLT изначально указывают на специальную процедуру редактора связей. Будучи вызвана, эта процедура по стеку вызова или другими средствами определяет, какую же процедуру пытались вызвать на самом деле, и настраивает ее запись в PLT (рис. 5.17). В случае, когда большинство программ не вызывает большую часть функций, как это часто и бывает при использовании разделяемых библиотек, это дает определенный выигрыш в производительности.



Рис. 5.17. Редактор связей времени исполнения

Пример 5.2. Структура PLT для процессора SPARC (цитируется по [docs.sun.com 816-0559-10])

Первые две (специальные) записи PLT до загрузки программы:
.PLT0:
un imp
unimp
unimp .PLTl:
unimp
unimp
unimp
Обычные записи PLT до загрузки программы:
.PLT101:
sethi (.-.PLT0),%gl
ba,a .PLTO
пор .PLT102:
sethi (.-.PLT0),%gl
ba,a .PLTO
nop
...
Специальные записи PLT после загрузки программы:
.PLT0:
save %sp,-64,°osp
call runtime-linker
пор
.PLT1:
.word identification
unimp
unimp
...
Обычные записи PLT после настройки:
PLT101:
sethi (.-.PLT0),%g1
sethi %hi(name1),%g1
jmpl %g1+%lo(namel),%g0
PLT102:
sethi (.-.PLT0),%g1
sethi %hi (name2),%g1
jmpl %g1+%lo(name2),%g0

Таким образом, каждая пользовательская задача, загруженная в Unix, имеет собственное адресное пространство с собственной структурой (рис. 5.18). Некоторые участки памяти у разных задач могут совпадать, и это позволяет сэкономить ресурсы за счет их разделения. Это существенно менее глубокое разделение, чем то, что достигается в Windows, но, как мы видели в разд. Динамические библиотеки более глубокое и принудительное разделение кода чревато серьезными проблемами.

Рис. 5.18. Разделяемые библиотеки ELF

Разделяемые объекты ELF идентифицируются по имени файла. Исполняемый модуль может ссылаться на файл как по простому имени (например, libc.so.1), так и с указанием пути (/usr/lib/libc.so.1). При поиске файла по простому имени редактор связей ищет его в каталогах, указанных в переменной среды LD LIBRARY_PATH, в записи RPATH заголовка модуля и, наконец, в каталогах по умолчанию, перечисленных в конфигурационном файле /var/ld/ld.config (именно в таком порядке, [docs.sun.com 816-0559-10]). При формировании имен каталогов могут использоваться макроподстановки с использованием следующих переменных.
$ISALIST— список систем команд— полезно на процессорах, поддерживающих несколько систем команд, например х86 и 8086, SPARC 32 и SPARC 64.
$ORIGIN — каталог, из которого загружен модуль. Полезно для загрузки приложений, которые имеют собственные разделяемые объекты.
SOSNAME, $OSREL — название и версия операционной системы.
$PLATFORM— тип процессора. Полезно для приложений, которые содержат в поставке бинарные модули сразу для нескольких процессоров, сетевых установок таких приложений, или сетевой загрузки в гетерогенной среде.
Понятно, что задание простого имени предпочтительнее, так как дает администратору системы значительную свободу в размещении разделяемых библиотек. Впрочем, системный редактор связей Idd позволяет изменять имена внешних ссылок и RPATH в уже построенном модуле, в частности заменяя одни файловые пути на другие, путевые имена на простые и наоборот. Благодаря этому, поставщик приложений для ОС, основанных на формате ELF, имеет гораздо меньше возможностей испортить жизнь системному администратору, чем поставщик приложений для Windows.
По стандартному соглашению, имя библиотеки обязательно содержит и номер версии (в обоих примерах это 1). В соответствии с требованиями фирмы Sun номер версии меняется, только когда интерфейс библиотеки меняется на несовместимый — убираются функции, изменяется их семантика и т. д. Из менее очевидных соображений [docs.sun.com 816-0559-10] требуется менять номер версии и при добавлении функции или переменной: ведь вновь добавленный символ может конфликтовать по имени с символом какой-то другой библиотеки.
Исправление ошибок, т. е. нарушение "bug-for-bug compatibility", основанием для изменения номера версии фирма Sun не считает. Напротив, в Linux принято снабжать разделяемые библиотеки минимум двумя, а иногда и более номерами версий — старшая (major) версия изменяется по правилам, приблизительно соответствующим требованиям Sun, а младшие (minor) — после исправления отдельных ошибок и других мелких изменений.
Благодаря этому соглашению, в системе одновременно может быть установлено несколько версий одного и того же модуля, а пользовательские программы могут ссылаться именно на ту версию, с которой разрабатывались и на совместимость с которой тестировались. Администратор может управлять выбором именно той библиотеки, на которую ссылаются конкретные модули, либо изменяя ссылки в этих модулях при помощи Idd, либо используя символические связи.