Динамические и статические библиотеки.
Начнём с повторения того, что мы уже знаем. Каждый исходный файл транслируется в объектный файл, после чего все объектные файлы линкуются в программу. Но иногда бывает кусок кода, который хочется переиспользовать. Мы могли бы оттранслировать объектные файлы этого куска один раз, после чего сразу с ними компилировать. Но так оказалось, что уже существует механизм сгруппировать объектные файлы вместе, после чего отдать их линковщику. Называется этот механизм...
Статические библиотеки.
// sum.cpp
int sum(int a, int b){
return a + b;
}
// four.cpp
#include <iostream>
int sum(int a, int b);
int main(){
std::cout << sum(2, 2) << std::endl;
}
#include <iostream>
int sum(int a, int b);
int main(){
std::cout << sum(2, 3) << std::endl;
}
И мы зачем-то пытаемся вычленить sum.cpp как библиотеку. Тогда сделать надо вот что: Компилируем:
g++ -c sum.cpp -o sum.o
ar rcs libsum.a sum.o
Что тут происходит?
- ar — сокращение от «archive».
- rcs — это некоторая магия (читайте man).
- libsum.a — название библиотеки.
Чтобы скомпилировать каждый из файлов выше с этой библиотекой, делаем так:
g++ four.cpp -lsum -L. -o four
g++ five.cpp -lsum -L. -o five
А что происходит тут?
- -L говорит, в каком каталоге искать библиотеку (в нашем случае в каталоге . — в текущем).
- -lsum говорит, что нам нужна библиотека, которая называется libsum.a (т.е. к тому, что идёт после -l спереди приписывается lib», а сзади — «.a»).
Тут больше совершенно ничего интересного, потому что статическая библиотека — набор объектных файлов, а про объектные файлы мы всё знаем. То ли дело...
Динамические библиотеки.
Пусть у вас есть библиотека, которая используется везде вообще. Например, libc. Если она статическая, то код библиотеки есть в каждой из программ. А значит в каждой программе они занимают место и на диске, и в памяти. Чтобы этого избежать, применяют динамические библиотеки.
Идея динамических библиотек в том, что мы ссылаемся как-то на внешнюю библиотечку, а потом на этапе исполнения грузим по надобности её части. Тогда она на диске лежит всего одна, и в память мы можем загрузить её один раз.
Давайте в примере выше сделаем статическую библиотеку динамической:
g++ -fpic -c sum.cpp -o sum.o
g++ -shared sum.o -o libsum.so
g++ four.cpp -lsum -L. -o four
g++ five.cpp -lsum -L. -o five
Что значат все консольные опции тут, уже пояснить намного сложнее, и мы поясним их в следующем параграфе.
А пока обратим внимание на то, что когда мы запустим four или five, нам на этапе исполнения скажут, что библиотека libsum.so не найдена. Хотя, казалось бы, вот она рядом лежит. Дело в том, что по умолчанию Linux ищет библиотеки только по системным путям. (Windows ищет и в текущей директории.) Чтобы проверить, от каких библиотек зависит ваша программа, запустите ldd ./four, и вам скажут, что нужна libsum.so, но её нет.
Есть два способа поправить сию оказию:
Первый — явно при запуске прописывать путь до библиотек.
Для этого существует переменная окружения LD_LIBRARY_PATH
, если присвоить ей точку, всё сработает
LD_LIBRARY_PATH=. ./four
Если вам нужно несколько путей, разделяйте их двоеточиями.
Второй — записать в саму программу, где искать библиотеки.
Это можно посмотреть при помощи objdump в секции Dynamic Section
, где есть RUNPATH
. Чтобы записать туда что надо, делается вот что:
g++ four.cpp -lsum -L. -Wl,-rpath=<путь_до_библиотеки> -o four
-Wl говорит, что опцию после него (т.е. -rpath) надо передать линковщику. Линковщику эта опция говорит, что в тот самый RUNPATH
надо записать тот путь, который вы попросили.
А какой надо просить? Не «.» ведь, потому что это путь, из которого вы запускаете программу, а не то место, где сама программа.
И тут вам на помощи приходит псевдо-путь $ORIGIN
, который и ссылается на место программы. Используя его Вы можете свободно написать что-нибудь по типу -rpath='$ORIGIN/../lib/'.
Впрочем, есть ещё и третий путь — использовать CMake, который будет делать всю грязную работу за вас, если написать ему команду add_library
.
Полезная статья про динамические библиотеки — How to write shared libraries, Ulrich Drepper.
Кстати, в Windows это работает иначе. В-нулевых, динамическая библиотека там называется не shared object, а dynamic load library. Во-первых, DLL-ки сразу же ищутся в текущем каталоге. Во-вторых, чтобы понять, что вы ссылаетесь на динамическую библиотеку, в Linux вы пишете -L. -lsum, а в Windows компиляция DLL создаёт вам специальный .lib-файл, который называется import-библиотекой, и с которым вы компилируете вашу программу, чтобы она сама поняла, откуда какие функции брать.
Причины нестандартной компиляции динамических библиотек.
Итак, в коде
g++ -fpic -c sum.cpp -o sum.o
g++ -shared sum.o -o libsum.so
Нас интересуют магические слова -fpic и -shared. Зачем на как-то особенно компилировать динамические библиотеки?
А дело вот в чём — при запуске программы, она первая загружается в адресное пространство, и она сама может выбрать, куда ей хочется. Динамические библиотеки такого же по понятным причинам позволить себе не могут. Возникает вопрос — и что теперь? А то, что при наличии глобальных переменных, мы не можем впаять им фиксированные адреса.
Есть путь, которым пошли разработчики архитектуры PowerPC: адреса динамической библиотеки считаются относительно некоторого регистра. И тут жить можно, разве что вам нужно правильно задавать этот регистр, когда обращаетесь к разным библиотекам, и не менять его, если обращаетесь к библиотечной функции из другой функции той же библиотеки. Сложно, но жить можно.
Самый простой способ жить с динамическими библиотеками был у Microsoft на 32-битной Windows. У каждой библиотеки был base-address — то куда библиотеке хочется загрузиться. Если там свободно — туда она и загружается, а если нет, то библиотеку загружают туда, где есть место, а в специальной отдельной секции (.reloc) хранится список адресов, которые надо исправить. Разумеется, в случае релокаций умирает переиспользование библиотеки, но Windows вам же полностью предоставляют, там можно расположить системные библиотеки так, как хочется, поэтому в проприетарных системах всё будет хорошо.
В Linux же это реализовано следующим образом. Смотрите как можем:
call next
next:
pop ABX
lea EAX, [EBX + (myvar - next)]
Тут идея в том, что мы записываем адрес текущей команды на стек, потом берём его со стека, а дальше вместо того, чтобы писать абсолютный адрес переменной myvar
, пишем относительный.
Относительный адрес позволяет нам обращаться к ней, если мы не знаем, в какое место памяти будет загружена
библиотека, что нам и надо.
Вообще мы не очень хорошо написали (процессор не любит непарные call
и pop
), поэтому обычно это выглядит так:
get_pc:
mov EBX, [ESP]
ret
get_variable:
call get_pc
next:
lea EAX, [EBX + (myvar - next)]
Этот код называется position-independent code, и ключ -fpic именно генерацией такого кода и занимается. Вопрос — почему для этого не сделали специальную инструкцию? А вот сделали, но в 64-битном режиме. Всё что с квадратными скобками стало уметь обращаться в память начиная со смещения текущей инструкции. И называется это RIP-relative positioning.
GOT/IAT. PLT.
В итоге мы имеем кусок данных, который можно загрузить и исполнять. Но на самом деле библиотека — это же не сферический код в вакууме, она хочет вызывать какие-то функции.
Например, библиотека для работы с JSON хочет делать fopen
. То есть нужно подружить библиотеки друг с другом. Самый простой вариант — когда мы делаем call
, в файл мы кладём нулевой адрес, а в секцию релокаций записываем, что вместо него нужно положить fopen
, после чего при запуске динамический загрузчик всё разложит по местам. То есть то же самое, что с линковщиком. Почему так не делают? Потому что мы от'mmap
'или нашу библиотеку, а в ней дырки. И во все места дырок нужно что-то подставить. И опять вы не можете записать библиотеку в память один раз, что вам
очень хочется.
Поэтому вместо этого просто заводят табличку со смещениями, и теперь все call
'ы обращаются туда, и туда же динамический загрузчик подставляет истинные адреса функций. Эта таблица в Linux называется global offset table, а в Windows — import address table.
Но на самом деле есть ещё проблема. Давайте посмотрим, что происходит, когда мы делаем
void foo();
void bar() {
foo();
}
Как мы обсуждали, тут будет call
и пустой адрес (до линковки пустой). А что будет, если foo
— это внешняя функция из какой-то библиотеки? Тогда надо бы вместо простого call
'а сделать call qword [got_foo]
. Но есть проблема — мы узнаём, откуда эта функция, только на этапе линковки, а компилировать надо раньше. Поэтому компилятор call foo
, а потом, если это было неправильно, просто создаёт свою функцию foo
, которая является прослойкой для jmp qword [got_foo]
. Такие заглушки, которые просто совершают безусловный переход по глобальной таблице смещений имеют название. В Linux их называют PLT (procedure linkage table), а в Windows как-то по-другому.
Но в Linux PLT используется ещё для одной цели. Рассмотрим, скажем, LibreOffice, в котором сотни динамических библиотек с тысячами функций в каждой. Поэтому заполнение GOT — это долго. И нам не хочется смотреть, где лежит каждая функция, после чего записывать её в таблицу. Поэтому эту операцию сделали ленивой:
GOT заполняется специальными заглушками, которые динамически ищут в хэш-таблице настоящий адрес функции, после чего записывают его в GOT вместо себя, и вызывают эту функцию, чтобы она отработала. В Microsoft по-умолчанию отложенная загрузка не используется, но его можно включить (delayed DLL loading или как-то так называется). Это фича загрузчика, а не самой Windows, и делает эта фича примерно то же самое. Однако есть разница. В Linux отсутствие библиотеки не позволяет запустить программу. В Windows же библиотека подгружается при первом вызове функции оттуда, что, по их словам, сделано чтобы вы могли за'if
'ать ситуацию, когда библиотеки нет.
Офф-топ на тему «Как страшно жить».
Поговорим про изменение so-файлов. Давайте возьмём и во время работы программы поменяем библиотечку на диске, втупую вписав туда другой текст. Результат поразителен — работа программы также изменится. Почему? Мы же, вроде как, исполняем библиотеку из оперативки, а не с диска. А дело в том, как работает copy-on-write в операционных системах. Когда вы пишете в некоторую страницу, вам копируют её. Но когда кто-то извне пишет в страницу, вам не дают копию старых данных. С исполняемым файлом такое не прокатывает, кстати. Это потому, что вашу программу загружает ядро, и оно может запретить изменять бинарники, а библиотеку загружает ваша программа, которая такого механизма не имеет.
Кстати, изменение и перекомпиляция — разные вещи. И если вы во время работы программы перекомпилируете библиотеку, она не обновится. Связано это с тем, что перекомпилированная библиотека — это новый файл, а не старый. По сути вы удалили старую библиотеку и создали новую, вместо того, чтобы библиотеку изменить. А в Linux пока кто-то имеет доступ к файлу, файл не удаляется до конца. И поэтому в вашей программе всё ещё есть та самая библиотека, которую вы загружали (а не новая).
Детали работы с динамическими библиотеками в Windows.
Никто не удивиться, что набор
call foo@PLT
foo@PLT:
jmp qword [got_foo]
не очень эффективен (три обращения в память вместо одного). Поэтому в Windows есть спецификатор __declspec(dllimport)
, который сразу вместо call 000000
и замены нулей на foo@plt
вставляет call qword [got_foo]
.
Ещё в Windows есть такая штука как .def-файл — линковщик экспортирует из вашей DLL-ки только то, что нужно, и в .def-файле указывается, что именно. Это хорошо работает в C, где имена символов и имена функций совпадают, но не очень хорошо в C++, где вам придётся писать сложные декорируемые имена. Поэтому есть второй вариант — написать на самой функции __declspec(dllexport)
.
И вроде бы всё хорошо, вы метите функции, которые экспортируете как __declspec(dllexport)
, которые импортируете — как __declspec(dllimport)
и всё классно работает. Но есть проблема: вы и в библиотеке, и в коде, который её использует, подключаете один заголовочный файл, где объявлена функция. И непонятно, что там писать: __declspec(dllexport)
или __declspec(dllimport)
. Для этого заводится специальный макрос под каждую библиотеку, которым отличают, саму DLL вы компилируете или кого-то с её использованием.
Есть ещё одна проблема. Непонятно, что делать с глобальными переменными. Там проблема ещё более страшная: вы сначала читаете адрес переменной из GOT (извините, IAT), а потом по полученному адресу обращаетесь. Тут уже никакую функцию-прослойку не написать, увы. Поэтому если вы не пометите глобальную переменную как __declspec(dllimport)
, тот тут вы уже точно совсем проиграете, у вас линковка не получится.
А ещё реализация DLL в Windows нарушает правила языка: если вы напишете inline
-функцию в заголовочном файле. Она просто откопируется в каждую библиотеку, где вы этот заголовок подключился. С этим вы ничего не сделаете, тут вы просто проиграли.
Детали работы с динамическими библиотеками в Linux.
Если вы думаете, что в Windows проблемы с динамическими библиотеками, потому что Windows — какашка, то сильно заблуждаетесь, потому что в Linux нюансов тоже выше крыши.
Итак, мем первый и основной — interposition. Есть такая переменная окружения, как LD_PRELOAD
. Она завставляет динамический загрузчик сначала обшарить в поисках динамических библиотек то, что вы в LD_PRELOAD
написали, а уже потом смотреть какие-нибудь RUNPATH
'ы и всё остальное. В частности, так можно подменить аллокатор (и мы так и делали, когда экспериментировали с mmap
'ом и munmap
'ом). Такая подмена и называется interposition. Теперь что же у него, собственно, за нюансы есть.
int sum(int a, int b) {
return a + b;
}
int test(int x, int y) {
return sum(x, y) - x;
}
Тут при обычной компиляции вторая функция просто вернёт свой второй аргумент. А при компиляции с -fpic, вы сможете подменить sum
, а значит оптимизации не будет. Чтобы это пофиксить, можно пометить sum
как static
(тогда эта функция будет у вас только внутри файла, а значит его не поменять извне) или как inline
(потому что inline
полагается на ODR, а значит функция должна быть везде одинаковой). Но есть ещё способ.
Linux по-умолчанию считает, что все функции торчат наружу (т.е. как __declspec(dllexport)
в Windows). А можно их пометить, как не торчащие наружу, а нужные только для текущей компилируемой программы/библиотеки: __attribute__((visibility("hidden")))
.
На самом деле атрибут visibility
может принимать несколько различных значений ("default"
, "hidden"
, "internal"
, "protected"
), где пользоваться сто́ит только вторым, потому что первый и так по-умолчанию, третий заставляет ехать все адреса, а четвёртый добавляет дополнительные аллокации.
При этом также есть различные ключи компиляции (типа -B symbolic), которые тем или иным образом немного меняют поведение, и пояснить разницу между ними всеми вам могут только избранные. И каждый из них может поменять вам поведение так, что вы легко выстрелите себе в ногу. То есть глобально в Linux поведение по умолчанию делаем вам хорошо, но, возможно, немного неоптимизированно, а когда вы начинаете использовать опции, вы погружаетесь в такую бездну, что ускорение заставляет вас очень много думать. Причём замедление от динамических библиотек может быть достаточно сильным: если взять компилятор clang-LLVM и компилировать при помощи его ядро Linux’а, то в зависимости от того, сложен ли clang-LLVM в один большой файл или разбит по библиотечкам, время компиляции отличается на треть. Поэтому ключи использовать придётся.
Один из самых безопасных из них — -fno-semantic-interposition. Это не то же самое, что и -fno-interposition потому, что бинарнику всё равно можно дать LD_PRELOAD
, однако в нашем случае функция test
будет оптимизирована.
Ещё один полезный ключ — -fno-plt. Он по сути вешает оптимизацию такую же, как __declspec(dllimport)
, но на весь файл, поэтому функции, написанные в нём же, замедляются. Чтобы не замедлялись — visibility("hidden")
. Вообще всё это детально и подробно рассказано не будет, если вам интересно, гуглите и читайте/смотрите по теме.
Впрочем, всякие -fno-plt и прочие штуки нужны нам тогда и только тогда, когда мы не включили linking-time оптимизации. В GCC все наборы ключей нафиг не нужны, если включить -flto. Так что в перспективе -flto и -fno-semantic-interposition — это единственное, что вам может быть нужно. Но только в перспективе.