Разделение кода приложения на несколько файлов упрощает работу над файлами. Например, определим файл print.asm с функцией, которая выводит на консоль некоторый текст:
global print ; Функция print выводит текст на консоль ; Параметры ; RDI - количество символов ; RSI - ссылка на строку section .text print: mov rdx, rdi mov rdi, 1 mov rax, 1 syscall ret
Эта функция принимает через регистр RSI ссылку на строку и через регистр RDI количество символов строки и с помощью системного вызова номер 1 (системный вызов write) выводит строку на консоль.
И пусть в файле app.asm расположен основной код программы, который использует функцию print:
global _start extern print section .data message: db "Hello World!",10, 0 count equ $ - message section .text _start: mov rdi, count lea rsi, [message] call print mov rax, 60 syscall
Здесь вызывается функция print, в которую передается строка из секции .data.
С помощью ассемблера NASM мы можем по отдельности по каждому файлу кода создать объектный файл и затем с помощью компоновщика мы сможем объединить их в один исполняемый бинарный файл
root@Eugene:~/asm# nasm -felf64 print.asm -o print.o root@Eugene:~/asm# nasm -felf64 app.asm -o app.o root@Eugene:~/asm# ld app.o print.o -o app root@Eugene:~/asm# ./app Hello World! root@Eugene:~/asm#
В итоге создаются объектные файлы "print.o" и "app.o", и затем они объединяются в файл "app". Мы можем запустить файл app и насладиться выводом строки "Hello World!". Однако данный подход в определенных ситуациях может иметь недостатки. Прежде всего код обоих объектных файлов объединяется в один исполняемый файл, что естественно увеличивает объем финального файла программы, если файлов очень много.
Другой аспект - мы могли бы использовать функцию print в других программах, чтобы аналогичным образом выводить строку на консоль. И было бы не плохо, если бы мы могли один раз скомпилировать эту функцию и многократно подключать в произвольное количество программ.
И Linux также позволяет использовать другой подход - dynamic linking (динамическое связывание или динамическая компоновка). При динамическом связывании код библиотек не включается в приложение, и библиотеки остаются отдельными файлами, а код приложения просто ссылается на них. Они объединяются только во время (или иногда после) запуска программы. Это более гибкий подход, поскольку если код находится в библиотеках, библиотеки можно обновлять отдельно от приложений. Поэтому, если в библиотеке возникла проблема с безопасностью, единственное, что нужно изменить, — это саму библиотеку. Это также экономит дисковое пространство, поскольку отдельные функции не копируются в каждую программу, а существуют только в одном месте файловой системы. Эти библиотеки еще называются shared libraries (общие или разделяемые библиотеки).
В системе Linux общие библиотеки также называются shared objectsы (общими объектами) и имеют расширение .so. Стоит отметить, что аналогичные библиотеки есть в других системах: на Windows это динамические библиотеки с расширением .dll, на Mac - библиотеки с расширением .dylib (хотя также используется расширение .so). Рассмотрим как создать свою разделяемую библиотеку.
Возьмем ранее рассмотренный код файла print.asm
global print:function ; формат объекта - function ; Функция print выводит текст на консоль ; Параметры ; RDI - количество символов ; RSI - ссылка на строку section .text print: mov rdx, rdi mov rdi, 1 mov rax, 1 syscall ret
Обратите внимание, что в начале файла при экспорте функции print указывается формат объекта - function
. Без подобного указания к функции print нельзя будет обращаться динамически.
Скомпилируем его в объектный файл с помощью ассемблера NASM:
nasm -felf64 print.asm -o print.o
Далее из объектного файла получим разделяемую библиотеку. Для этого компоновщику передается параметр -shared:
ld -shared print.o -o print.so
В итоге будет создан файл "print.so", который собственно и представляет разделяемую библиотеку и который мы можем динамически подключать в другие приложения.
Простестируем библиотеку. Для этого определим следующий файл app.asm:
global _start extern print section .data message: db "Hello World!",10, 0 count equ $ - message section .text _start: mov rdi, count lea rsi, [message] call print mov rax, 60 syscall
Здесь также вызывается функция print, в которую передаются адрес строки и количество символов.
С помощью ассемблера NASM скомпилируем объектный файл:
nasm -felf64 app.asm -o app.o
Далее скомпонуем его с динамической библиотекой:
ld --dynamic-linker=/lib64/ld-linux-x86-64.so.2 app.o print.so -o app
В данном случае компоновщику ld передается с помощью параметра --dynamic-linker
динамический загрузчик/компоновщик, который применяется для загрузки/компоновки приложения с разделяемой библиотекой. На архитектуре х86-64 это файл /lib64/ld-linux-x86-64.so.2. И далее передаются файлы приложения и самой библиотеки.
Запустим скомпилированный файл app. Но несмотря на успешную компиляцию при выполнении программы мы столкнемся с ошибкой следующего вида::
root@Eugene:~/asm# ./app ./app: error while loading shared libraries: print.so: cannot open shared object file: No such file or directory
Как видно мы сталкиваемся с ошибкой - загрузчик не может найти библиотеку "print.so". Дело в том, что по умолчанию поиск библиотек выполняется в некоторых стандартных каталогах операционной системы, в частности, в каталоге /lib, либо в каталогах, которые установлены в переменной окружения LD_LIBRARY_PATH. Но наша библиотека располагается не в этих каталогах, а в текущем каталоге, где также находится исполняемый файл app. Поэтому передадим переменной LD_LIBRARY_PATH путь к текущему каталогу и повторно запустим приложение:
root@Eugene:~/asm# export LD_LIBRARY_PATH=. root@Eugene:~/asm# ./app Hello World! root@Eugene:~/asm#
Обратите внимание, что поиск начинается с каталогов, определенных в LD_LIBRARY_PATH, и продолжается до стандартных каталогов.
Полный консольный вывод:
root@Eugene:~/asm# nasm -felf64 print.asm -o print.o root@Eugene:~/asm# ld -shared print.o -o print.so root@Eugene:~/asm# nasm -felf64 app.asm -o app.o root@Eugene:~/asm# ld --dynamic-linker=/lib64/ld-linux-x86-64.so.2 app.o print.so -o app root@Eugene:~/asm# ./app ./app: error while loading shared libraries: print.so: cannot open shared object file: No such file or directory root@Eugene:~/asm# export LD_LIBRARY_PATH=. root@Eugene:~/asm# ./app Hello World! root@Eugene:~/asm#
При компоновке в файл ELF добавляется путь к динамическому загрузчику. В примере выше при компоновке приложения указывался встроенный динамический загрузчик /lib64/ld-linux-x86-64.so. Для этого применялась команда:
ld --dynamic-linker=/lib64/ld-linux-x86-64.so.2 app.o print.so -o app
Если не указывать загрузчик, то ld выберет путь по умолчанию, что может привести к несуществующему файлу. Но если динамический компоновщик не существует, то при попытке запустить приложение мы получим сооющение об ошибке:
root@Eugene:~/asm# ld app.o print.so -o app root@Eugene:~/asm# ./app bash: ./app: cannot execute: required file not found
При запуске исполняемого файла операционная система выделяет программе адресное пространство и выполняет отображение памяти в соответствии с таблицей заголовков программы. После выделения памяти программе в дело вступает динамический загрузчик. Загрузчик устанавливает зависимости программы и загружает их. Также загрузчик выполняет релокацию приложения и его зависимостей, инициализирует приложение и его зависимости, и в конце передает управление приложению. После этого собственно выполняется приложение, а пользователь может с ним взаимодействовать.
С помощью команды ldd можно посмотреть все используемые библиотеки и места их загрузки в память (которые могут меняться при каждом вызове). Например, в моем случае результат выглядит так
root@Eugene:~/asm# ldd app linux-vdso.so.1 (0x00007ffc455f3000) print.so => ./print.so (0x00007efc8c478000) root@Eugene:~/asm#
Здесь первая представляет библиотеку "linux-vdso.so.1". Это специальная библиотека, называемая библиотекой vDSO и предоставляемая самим ядром Linux. Эта библиотека позволяет быстро выполнять определенные функции ядра, такие как функции времени, для доступа к которым не требуется какой-либо особый уровень привилегий. Вызов этих функций позволяет получить общедоступную системную информацию без фактического вызова системного вызова.
Вторая запись представляет подгружаемую библиотеку "print.so"
Когда компилируется код приложения, некоторые символы/идентификаторы отсутствуют в самом основном коде (например, в коде выше это функция print
).
Затем компилятор/компоновщик проверяет список библиотек, которые надо скомпилировать вместе с основным кодом приложения, а также проверяет, что все символы/идентифкаторы,
которых нет в основном коде приложения, находятся в одной из общих библиотек. Затем компоновщик заносит все общие библиотеки, на которые ссылается приложение,
в исполняемый файл. При этом компоновщики не записывает в исполняемый файл загрузчик или библиотеку vDSO, поскольку они предоставляются/вызываются самим ядром.
Каждый найденный символ функции добавляется как в таблицу Procedure Linkage Table или PLT (таблица связей процедур), так и в таблицу Global Offset Table или GOT (глобальную таблицу смещений). Таблица PLT содержит инструкцию косвенного перехода к месту, указанному в GOT. Причем таблица GOT не содержит непосредственное местоположение функции. Вместо этого GOT содержит код-загрушку, который сообщает загрузчику найти функцию и заменить в GOT ее на фактическое значение функции (это называется отложенной загрузкой). Эта позволяет быстро загружать исполняемый файл, а по мере того, как программа продолжает работать, код-загрушка заменяется на реальное значение функции. Это позволяет не ждать замены кода-заглушки всех идентифкаторов, которые, возможно, никогда не будут использованы.