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

Подключение разделяемых библиотек

Последнее обновление: 29.09.2023

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

.globl main
 
.data
message: .asciz "Hello METANIT.COM\n"  # строка для вывода на консоль
 
.text
main:
    subq $8, %rsp           # выравнивание должно быть по 16 байтам
    movq $message, %rdi     # первый параметр функции printf - строка
    call printf
    addq $8, %rsp
    ret

Для компиляции применялась следующая команда

 gcc hello.s -static -o hello

Флаг -static позволял при компиляции включить в создаваемый исполняемый файл код статических библиотек. Однако минусом данного подхода является очень большой размер исполняемого файла. Так, программа выше компилируется в файл размером порядка 1 мегабайта. 1 мегабайт, чтобы воспользоваться функцией printf и вывести на консоль строку.

Но компилятор GCC позволяет также использовать другой подход - dynamic linking (динамическое связывание или динамическая компоновка). При динамическом связывании код библиотек не включается в приложение, и библиотеки остаются отдельными файлами, а код приложения просто ссылается на них. Они объединяются только во время (или иногда после) запуска программы. Это более гибкий подход, поскольку если код находится в библиотеках, библиотеки можно обновлять отдельно от приложений. Поэтому, если в библиотеке возникла проблема с безопасностью, единственное, что нужно изменить, — это саму библиотеку. Это также экономит дисковое пространство, поскольку отдельные функции не копируются в каждую программу, а существуют только в одном месте файловой системы. Эти библиотеки еще называются shared libraries (общие или разделяемые библиотеки).

В системе Linux общие библиотеки также называются shared objects (общими объектами) и имеют расширение .so. Стоит отметить, что аналогичные библиотеки есть в других системах: на Windows это динамически подключаемые библиотеки с расширением .dll, на Mac - динамические библиотеки с расширением .dylib (хотя также используется расширение .so).

Итак, для динамического свзывания библиотек с кодом программы при компиляции вместо флага -static надо использовать другой флаг - -rdynamic:

gcc -rdynamic hello.s -o hello

Стоит отметить, что на некоторых системах может потребоваться применение дополнительного флага -no-pie

gcc -rdynamic -no-pie hello.s -o hello

Теперь скомпилированная программа будет динамически загружать необходимые библиотеки при вызове. Размер исполняемого файла уменьшается с мегабайта примерно до 16 килобайт. Это связано с тем, что функция printf привнесла множество зависимостей в библиотеку C, которые при статическом связывании складывались в большой объем кода, скомпилированного в окончательный исполняемый файл. Теперь, когда мы динамически компонуем, все это остается в библиотеке C.

Мы можем даже просмотреть список зависимостей, запустив команду

ldd hello

Эта команда перечисляет все используемые библиотеки и места их загрузки в память (которые могут меняться при каждом вызове). Например, в моем случае результат выглядит так

root@Eugene:~/asm# ldd hello
        linux-vdso.so.1 (0x00007ffff6f11000)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f22efb92000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f22efdc1000)
root@Eugene:~/asm#

Самая последняя запись в этом списке представляет загрузчик

/lib64/ld-linux-x86-64.so.2

Загрузчик — это программа, которая читает файл программы и загружает его в память, а также любые связанные библиотеки. Загрузчик обычно имеет имя ld.so. Это библиотека, которая фактически сама загружает. Фактически, это сам исполняемый файл. И мы даже можем вручную запустить файл /lib64/ld-linux-x86-64.so.2 как самостоятельную программу.

Когда мы запускаем исполняемый файл, то фактически начинается выполнение загрузчика, которому в качестве параметра передается имя программы. Например, команда

/lib64/ld-linux-x86-64.so.2 ./hello

аналогично запустит и выполнит выше скомпилированное приложение.

Следующая запись:

libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f22efb92000)

Представляет библиотеку C версии 6. Стрелка (=>) после имени библиотеки указывает, где в системе можно найти библиотеку. Это позволяет исполняемому файлу узнать, на какую библиотеку ссылаться, а загрузчику динамической компоновки — где ее найти.

И сама первая запись:

linux-vdso.so.1 (0x00007ffff6f11000)

представляет библиотеку "linux-vdso.so.1". Это специальная библиотека, называемая библиотекой vDSO и предоставляемая самим ядром Linux. Эта библиотека позволяет быстро выполнять определенные функции ядра, такие как функции времени, для доступа к которым не требуется какой-либо особый уровень привилегий. Вызов этих функций позволяет получить общедоступную системную информацию без фактического вызова системного вызова.

Как работает загрузчик

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

Каждый найденный символ функции добавляется как в таблицу Procedure Linkage Table или PLT (таблица связей процедур), так и в таблицу Global Offset Table или GOT (глобальную таблицу смещений). Таблица PLT содержит инструкцию косвенного перехода к месту, указанному в GOT. Причем таблица GOT не содержит непосредственное местоположение функции. Вместо этого GOT содержит код-загрушку, который сообщает загрузчику найти функцию и заменить в GOT ее на фактическое значение функции (это называется отложенной загрузкой). Эта позволяет быстро загружать исполняемый файл, а по мере того, как программа продолжает работать, код-загрушка заменяется на реальное значение функции. Это позволяет не ждать замены кода-заглушки всех идентифкаторов, которые, возможно, никогда не будут использованы.

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

Так, например, вызов инструкции printf() заставит загрузчик сделать следующее:

  1. Создает запись для printf в таблице GOT, которая изначально представляет функцию поиска для этого идентификатора printf.

  2. Создает запись для printf в таблице PLT, настраивает ее на переход к функции, указанной в GOT. Этот символ называется printf@plt.

  3. Изменяет вызов call printf в коде на вызов call printf@plt. Таким образом, в процессе выполнения код не изменяется, и во время выполнения будет изменена только соответствующая запись в таблице GOT.

  4. Когда функция printf@plt вызывается в первый раз, загрузчик исправляет значение в GOT, чтобы оно указывало на реальную функцию printf.

  5. При последующих вызовах printf@plt будет идти переход к записи в GOT, которая будет указывать на саму функцию printf.

Что касается глобальных объектов, которые есть в библиотеках Си (например, глобальный объект stdout, который представляет дескриптор стандартного вывода), эти объекты записываются в таблицу GOT сразу, когда загрузчик загружает программу. Однако код приложения может использовать их напрямую (без обращения к таблице GOT), поскольку, хотя подобные объекты (как stdout) определяется внешней библиотекой, определение глобального объекта фактически помещается компоновщиком в основную программу. Это еще называется copy relocation (перемещение/релокация копии).

Помощь сайту
Юмани:
410011174743222
Перевод на карту
Номер карты:
4048415020898850