В прошлой главе был рассмотрен вызов стандартных функций языка Си в коде ассемблера. В частности, у нас была следующая программа:
.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()
заставит загрузчик сделать следующее:
Создает запись для printf
в таблице GOT, которая изначально представляет функцию поиска для этого идентификатора printf
.
Создает запись для printf
в таблице PLT, настраивает ее на переход к функции, указанной в GOT. Этот символ называется printf@plt
.
Изменяет вызов call printf
в коде на вызов call printf@plt
. Таким образом, в процессе выполнения код не изменяется, и во время выполнения будет изменена только
соответствующая запись в таблице GOT.
Когда функция printf@plt
вызывается в первый раз, загрузчик исправляет значение в GOT, чтобы оно указывало на реальную функцию printf
.
При последующих вызовах printf@plt
будет идти переход к записи в GOT, которая будет указывать на саму функцию printf
.
Что касается глобальных объектов, которые есть в библиотеках Си (например, глобальный объект stdout, который представляет дескриптор
стандартного вывода), эти объекты записываются в таблицу GOT сразу, когда загрузчик загружает программу.
Однако код приложения может использовать их напрямую (без обращения к таблице GOT), поскольку, хотя подобные объекты (как stdout
) определяется внешней библиотекой,
определение глобального объекта фактически помещается компоновщиком в основную программу. Это еще называется copy relocation (перемещение/релокация копии).