Как стать автором
Обновить

Про компоновку, dependency hell и обратную совместимость

Время на прочтение11 мин
Количество просмотров22K
В данной статье речь пойдёт о высокоуровневом взгляде на компоновку. Где ищутся разделяемые библиотеки на Linux, BSD*, Mac OS X, Windows, от которых зависят приложения? Что делать с обратной совместимостью? Как бороться с адом зависимостей?

Предполагается, что читатель знаком с такими наборами символов как «компилятор», «объектный файл», «компоновщик», «статическая библиотека», «динамическая библиотека», «динамический загрузчик» и некоторыми другими, поэтому разжёвывать мы ничего не будем.

Проблемы статической загрузки динамических библиотек:

  1. main.exe зависит от version-0.3.dll и bar.dll. bar в свою очередь, зависит от version-0.2.dll, которая бинарно не совместима с версией 0.3 (не просто символы отсутствуют, а совпадают имена, но различное число аргументов, или создают объекты разной природы и т. п.). Затрут ли символы из version-0.2.dll оные из version-0.3.dll? Тот же вопрос стоит тогда, когда используется одна статическая версия библиотеки (скажем, version-0.2.lib) и динамическая (version-0.3.dll);
  2. создание перемещаемых приложений: где динамический загрузчик будет искать version-0.?.dll и bar.dll для приложения из предыдущего пункта? Найдёт ли он зависимости main.exe, если тот будет перемещён в другую папку? Как нужно собрать main.exe, чтобы зависимости искались относительно исполняемого файла?
  3. dependency hell: две версии одной библиотеки /opt/kde3/lib/libkdecore.so и /opt/kde4/lib/libkdecore.so (с которой плазма уже не падает), половина программ требуют первую, другая половина программ — вторую. Обе библиотеки нельзя поместить в одну область видимости (один каталог). Эта же проблема есть и в п. 1, т. к. надо поместить две версии библиотеки version в один каталог.

После прочтения первого пункта читатель может воскликнуть: «Да это извращение! Так не делают! Надо использовать одну версию библиотеки!» Да, это так, но в жизни всякое бывает. Простейший пример: ваше приложение использует библиотеку а, и стороннюю закрытую библиотеку (я подчёркиваю это, чужой платный продукт) б, которая ну вот никак не может использовать ту же версию а, что и вы или наоборот.

Другим примером в рамках огромного проекта служит тот факт, что выпуск различных частей имеет разный период (с чем наша команда столкнулась и что вообще послужило причиной написания данного текста). Таким образом, главный продукт может время от времени выходить в конфигурации, описанной в пункте 1.

Dependency hell больше актуален для разработчиков системных библиотек и операционных систем, но и в прикладной области может возникнуть. Опять же предположим, что имеется огромный проект, в котором несколько исполняемых программ. Все они зависят от одной библиотеки, но разных версий. (Это не та же ситуация, что и в п. 1: там в один процесс загружается две версии одной библиотеки, а здесь в каждый процесс загружается только одна, но каждый использует свою версию).

Побег из ада

Ответ прост: надо добавить версию в имя файла библиотеки. Это позволит размещать файлы библиотек в одном каталоге. При этом рекомендуется добавлять версию ABI, а не API, порождая тем самым две параллельных ветки версий и соответствующие трудности.

Контроль версии – очень рутиная работа. Рассмотрим схему x.y.z:
  • x – мажорный выпуск. Ни о какой совместимости речи не идёт;
  • y – минорный выпуск. Либа совместима на уровне исходных текстов, но двоичная совместимость может быть сломана;
  • z – багофикс. Либа совместима в обе стороны.

Тогда в имя файла разумно включить x.y. Если при увеличении минорной версии совместимость сохранили, то достаточно сделать соответствующий симлинк:
version-1.1.dll
version-1.0.dll → version-1.1.dll

Будут работать и приложения, использующие version-1.1.0, и те, кто использует version-1.0.x.

Если совместимость сломали, то в системе будет два файла и снова всё будет работать.

Если по каким-то причинам совместимость сломали при багофиксе, то должна быть увеличена минорная версия (и нечего фейлится, как это сделала команда любимейшего Qt [1]).

Кстати говоря, никто не запрещает вообще включить версию API – тогда символических ссылок будет больше, т.к. совместимость чаще сохраняется. Зато в этом случае упомянутый фейл Qt разрулился бы легко и не заставил увеличивать минорную версию.

Это справедливо для всех платформ.

Решение оставшихся двух вопросов отличается в зависимости от ОС.

ELF & GNU ld (Linux, *BSD, etc)

В разделяемой библиотеке формата ELF присутствует так называемое SONAME [2][3]. Это – строка символов, которая прописывается в двоичный файл в секцию DT_SONAME. Просмотреть SONAME для библиотеки можно, например, так:
$ objdump -p /path/to/file | grep SONAME

Если программа/библиотека faz связывается с библиотекой baz, которая имеет SONAME = baz-0.dll, то строка baz-0.dll будет жёстко прописана в двоичном файле faz в секции DT_NEEDED, и при его запуске динамический загрузчик будет искать файл с именем baz-0.dll. При этом никто не запрещает назвать файл по-другому!

Просмотреть SONAME'ы, от которых зависит исполняемый файл можно так:
$ objdump -x /path/to/file | grep NEEDED

Динамический загрузчик ищет библиотеки из секции DT_NEEDED в следующих местах в данном порядке [4][5]:
  1. список каталогов в секции DT_RPATH, которая жёстко прописана в исполняемом файле. Поддерживается большинством *nix-систем. Игнорируется, если присутствует секция DT_RUNPATH;
  2. LD_LIBRARY_PATH – переменная окружения, также содержит список каталогов;
  3. DT_RUNPATH – тоже самое, что и DT_RPATH, только просматривается после LD_LIBRARY_PATH. Поддерживается только на самых свежих Unix-подобных системах;
  4. /etc/ld.so.conf – файл настроек динамического загрузчика ld.so, который содержит список папок с библиотеками;
  5. жёстко зашитые пути – обычно /lib и /usr/lib.

Формат данных для RPATH, LD_LIBRARY_PATH и RUNPATH такой же, как и для PATH: список путей, разделённых двоеточием. Просмотреть RUNPATH'ы можно, например, так:
$ objdump -x /path/to/file | egrep 'R(|UN)PATH'

R[UN]PATH может содержать специальную метку $ORIGIN, которую динамический загрузчик развернёт в полный абсолютный путь до загружаемой сущности. Здесь стоит отметить, что некоторые разработчики добавляют в RUNPATH “.” (точку). Это не тоже самое, что $ORIGIN! Точка развернётся в текущий рабочий каталог, который естественно не обязан совпадать с путём до сущности!

Для демонстрации написанного, разработаем приложение по схеме из п. 1 (ссылка на хранилище в гитхабе: github.com/gshep/linking-sample). Чтобы собрать всю систему достаточно перейти в корень папки и вызвать ./linux_make_good.sh, результат будет в папке result. Ниже будут разобраны некоторые этапы сборки.

На этапе компоновки библиотек version-0.x задаются SONAME:
$ gcc -shared -Wl,-soname,version-0.3.dll -o version-0.3.dll version.o

Они зависят только от системных библиотек и поэтому не требуют наличия секций R[UN]PATH.

Библиотека bar уже зависит от version-0.2, поэтому нужно указать RPATH:
$ gcc -shared -Wl,-rpath-link,/path/to/version-0.2/ -L/path/to/version-0.2/ -l:version-0.2.dll -Wl,-rpath,\$ORIGIN/ -Wl,--enable-new-dtags -Wl,-soname,bar.dll -o bar.dll bar.o

Параметр --enable-new-dtags указывает компоновщику заполнить секцию DT_RUNPATH.

Параметр -Wl,-rpath,... позволяет заполнить секцию R[UN]PATH. Для задания списка путей можно указать параметр несколько раз, либо перечислить все пути через двоеточие:
$ gcc -Wl,-rpath,/path/1/ -Wl,-rpath,/path/2 ...
$ gcc -Wl,-rpath,/path/1:/path/2 ...

Теперь всё содержимое папки result целиком или саму папку можно перемещать по файловой системе как угодно, но при запуске динамический загрузчик найдёт все зависимости и программа исполнится:
$ ./result/main.exe
Hello World!
bar library uses libversion 0.3.0, number = 3
version::get_version result = 0
But I uses liversion 0.3.0
number = 3

Вот мы и подошли к проблеме затирания символов! Bar использует version-0.2.dll, в которой get_number() возвращает 2, а само приложение version-0.3.dll, где та же функция возращает уже 3. По выводу приложения видно, что одна версия функции get_number затирается другой.

Дело в том [6; Dynamic Linking and Loading, Comparison of dynamic linking approaches], что GNU ld & ELF не использует SONAME или имя файла в качестве пространства имён для импортируемых символов:
если разные библиотеки экспортируют сущности с одними и теми же именами, то одни из них будут перетирать другие и в лучшем случае программа упадёт.

Случай, когда одна из библиотек суть статическая, решается просто: все символы статической библиотеки должны быть скрыты [7, 2.2.2 Define Global Visibility].

К сожалению, в случае динамических библиотек не всё так просто. У компоновщика/загрузчика GNU отсутствует такая функциональность, как прямое связывание [8]. Кто-то пилил эту возможность в Генту [9], но кажется, всё заглохло. В Солярке она есть [10][11], но сама Солярка сдохла…

Одним из возможных вариантов является версионирование самих символов [7, 2.2.5 Use Export Maps]. На самом деле это больше похоже на декорирование символов. (Можно только представлять, что сейчас кричит читатель, программирующий на Си++...)

Данный способ заключается в том, чтобы создать так называемый версионный сценарий, в котором перечислить все экспортируемые и скрытые сущности [12][13]. Пример сценария из version-0.3:
VERSION_0.3 {
    global:
        get_version;
        get_version2;
        get_number;
    local:
        *;
};

На этапе компоновки указать данный файл с помощью параметра --version-script=/path/to/version.script. После этого приложение, которое будет связано с такой либой получит в NEEDED version-0.3.dll, а в таблице импорта неопределённый символ get_number@@VERSION_0.3, хотя в заголовочных файлах по-прежнему будет просто int get_number().

Натравите nm на любую программу, которая использует glibc, и вы прозреете!

Чтобы собрать пример с использованием версионирования символов в библиотеках version-0.x запустите корневой сценарий linux_make_good.sh с параметром use_version_script:
$ ./linux_make_good.sh use_version_script
$ ./result/main.exe
Hello World!
bar library uses libversion 0.2.0, number = 2
version::get_version result = 0
But I uses liversion 0.3.0
number = 3

$ nm ./result/main.exe
// …
                 U get_number@@VERSION_0.3
                 U get_version@@VERSION_0.3
0000000000401008 T main
                 U memset@@GLIBC_2.2.5

$ nm ./result/bar.dll
// …
                 U get_number@@VERSION_0.2
                 U get_version2@@VERSION_0.2
0000000000000800 t register_tm_clones
                 U strcat@@GLIBC_2.2.5

http://nooooooooooooooo.com/
  • Эй, Дарт! Наша libX будет поддерживать Линукс!
  • Noooooooooooooooooooooooooo!


Да, после фейла, с которым наша команда столкнулась, капитан принял волевое решение и теперь используется только одна версия либы (именно из-за Линукса).

Как обстоят дела на Маке?

Мак Ось использует формат Mach-o для исполняемых файлов, а для поиска символов двух-уровневое пространство имён [14, Two-level namespace][16]. Это по-умолчанию сейчас, но можно собрать с плоским пространством имён или вообще отключить его при запуске программы [15, FORCE_FLAT_NAMESPACE]. Узнать, собран ли бинарник с поддержкой пространства имён поможет команда:
$ otool -hv /path/to/binary/file

То есть не надо париться с каким-то дополнительным декорированием имён – просто включить версию в имя файла!

А что же с поиском зависимостей?

В макоси почти всё аналогично, только называется по-другому.

Вместо SONAME есть id библиотеки или install name. Просмотреть можно, например, так:
$ otool -D /usr/lib/libstdc++.dylib
Изменить можно с помощью install_name_tool.
При связывании с библиотекой её id прописывается в бинарнике.

Просмотреть зависимости бинарника можно так:
$ otool -L /path/to/main.exe
или
$ dyldinfo -dylibs /path/to/main.exe

При запуске dyld пытается открыть файл с именем «id» [15, DYNAMIC LIBRARY LOADING], т. е. рассматривает install name как абсолютный путь к зависимости. Если потерпел неудачу – то ищет файл с именем/суффиксом «id» в каталогах, перечисленных в переменной окружения DYLD_LIBRARY_PATH (полный аналог LD_LIBRARY_PATH).

Если поиск в DYLD_LIBRARY_PATH не дал результатов, то dyld аналогично просматривает ещё парочку переменных окружения [15], после чего поищет либу в стандартных каталогах.

Такая схема не позволяет собирать перемещаемые приложения, поэтому была введена специальная метка, которую можно прописывать в id: @executable_path/. Эта метка во время загрузки будет развёрнута в абсолютный путь до исполняемого файла.

Далее, можно поменять зависимости у готового бинарника:
$ install_name_tool -change /usr/lib/libstdc++.dylib @executable_path/libstdc++.dylib main.exe

Теперь загрузчик сначала будет искать эту либу в той же папке, где и main.exe. Чтобы не менять в готовом бинарнике, надо во время компоновки подсунуть либу libstdc++.dylib, у которой id = @executable_path/libstdc++.dylib.

Далее, возникает одна проблема, а точнее две. Пусть есть такая иерархия:
  • main.bin
  • tools/
    • auxiliary.bin
  • library.dll

main.bin зависит от library.dll, но и tools/auxiliary.bin зависит от неё же.
При этом id либы = @executable_path/library.dll, и оба бинарника были просто с ней скомпонованы. Тогда при запуске auxiliary.bin загрузчик будет искать /path/to/tools/library.dll и естественно не найдёт! Конечно можно ручками после компоновки подправить tools/auxiliary.bin или кинуть мягкую ссылку, но опять неудобства!

Ещё лучше проблема проявляет себя, когда речь заходит о подключаемых модулях (plugins):
  • main.bin
  • plugin/
    • 1.plugin
    • helper.dylib

1.plugin имеет запись @executable_path/helper.dylib, но во время запуска она развернётся в абсолютный путь до main.bin, а не 1.plugin!

Для решения этой проблемы яблочники с версии Оси 10.4 ввели новый маркер: @loader_path/. Во время загрузки зависимости, этот маркер развернётся в абсолютный путь к бинарнику, который дёргает зависимость.

Последняя сложность заключается в том, что надо две версии связываемых библиотек: одни будут устанавлены в систему, и иметь id = /usr/lib/libfoo.dylib, а другие использованы для сборки проектов, и их id = @loader_path/libfoo.dylib. Это легко решить с помощью install_name_tool, но утомительно; поэтому с версии 10.5 ввели метку @rpath/. Библиотека собирается с id = @rpath/libfoo.dylib и копируется куда угодно. Бинарник собирается со списком путей для поиска зависимостей, в котором разрешено использовать @{executable, loader}_path/:
$ gcc ... -Xlinker -rpath -Xlinker '@executable_path/libs' -Xlinker -rpath -Xlinker '/usr/lib' ...

Это аналогично RPATH/RUNPATH для ELF. При запуске бинарника строка @rpath/libfoo.dylib будет развёрнута в @executable_path/libs/libfoo.dylib, которая уже развернётся в абсолютный путь. Либо развернётся в /usr/lib/libfoo.dylib.

Просмотреть зашитые в бинарник rpath'ы можно так:
$ otool -l main.bin | grep -A 2 -i lc_rpath

Удалить, изменить или добавить rpath'ы можно с помощью install_name_tool.

Проверяем на примере:
$ ./macosx_make_good.sh 
Building version-0.2
Building version-0.3
Building bar
Building fooapp
$ ./result/main.exe 
Hello World!
bar library uses libversion 0.2.0, number = 2
version::get_version result = 0
But I uses liversion 0.3.0
number = 3

На айОС всё так же.

Как видно из примера, Mac OS X в плане динамических библиотек лучше Linux & Co.

И наконец, Windows!

Тут тоже всё хорошо [6; Dynamic Linking and Loading, Comparison of dynamic linking approaches]. Надо только добавить версию в имя файла и… симлинков нет! То есть они есть, но на них многие жалуются и работают они только на NTFS (Windows XP точно можно установить на FAT раздел). Следовательно, обратная совместимость может стоить приличного места на диске… Ну и ладно. )

Чтобы собрать пример на Windows потребуется запустить консоль Visual Studio, в которой уже будет настроено окружение. Далее сборка и запуск:
> .\windows_make_good.bat
// ...
>.\result\main.exe
Hello World!
bar library uses libversion 0.2.0, number = 2
version::get_version result = 0
But I uses liversion 0.3.0
number = 3

Либы ищутся только так [17]. Одним из возможных способов смены алгоритма поиска зависимостей является использование файла настроек приложения (application configuration file) и свойства privatePath у probing [18]. Однако данный способ применим только начиная с Windows 7/Server 2008 R2.

А ещё есть WinSxS и так называемые сборки (assemblies) [19]. Это – тема отдельной статьи. Однако пока писалась эта статья, снизошло озарение и понимание, что эти самые сборки нужны лишь для того (по крайней мере, Сишникам и Си++никам) чтобы все приложения компоновались, скажем, с comdlg32.dll, но все использовали разные версии.

Заключение

Все основные платформы позволяют относительно просто создавать приложения, которые могут быть установлены обычным копированием. Однако проблемы dependency hell, обратной совместимости и затирания символов разработчики должны решать самостоятельно.

Основным решением является выбор правильного версионирования и контроля за ним.

В то время, как Curiosity бороздит марсианские просторыавтор пытался здесь поведать о том, как избежать затирания символов, на хабре уже давно есть статьи, где рассказано, как специально добиться обратного: habrahabr.ru/post/106107, habrahabr.ru/post/115558.

П.С. Пока велась работа над данной статьёй, автор посетил конференцию “На стачку!”, где послушал доклад К. Назарова из Parallels о версионировании [20]. Ничего неожиданного или необычного там не прозвучало, но было приятно услышать, что в такой известной компании осознали проблему и сделали правильные выводы. Из нового для себя автор вынес оттуда ссылку: semver.org.

Пользуясь возможностью, хочу поблагодарить своих коллег Александра Сидорова и Александра Прокофьева за конструктивную критику и ценные замечания!

Ссылки

  1. ^  QtMultimedia changes-5.0.1
  2. ^  http://en.wikipedia.org/wiki/Soname.
  3. ^  Program Library HOWTO, 3.1.1. Shared Library Names.
  4. ^  man ld-linux.so.
  5. ^  http://en.wikipedia.org/wiki/Rpath.
  6. ^ 1 2 Linkers and Loaders by John R. Levine, http://www.iecc.com/linker/.
  7. ^ 1 2  How To Write Shared Libraries by Ulrich Drepper, http://www.akkadia.org/drepper/dsohowto.pdf(pdf).
  8. ^  http://en.wikipedia.org/wiki/Direct_binding.
  9. ^  https://bugs.gentoo.org/show_bug.cgi?id=114008.
  10. ^  https://blogs.oracle.com/msw/date/20050614.
  11. ^  http://cryptonector.com/2012/02/dll-hell-on-linux-but-not-solaris/.
  12. ^  https://sourceware.org/binutils/docs/ld/VERSION.html.
  13. ^  http://www.tux.org/pub/tux/eric/elf/docs/GNUvers.txt.
  14. ^  man ld.
  15. ^ 1 2 3 man dyld.
  16. ^  http://en.wikipedia.org/wiki/Mach-O#Mach-O_file_layout.
  17. ^  MSDN, Dynamic-Link Library Search Order, http://msdn.microsoft.com/en-us/library/windows/desktop/ms682586%28v=vs.85%29.aspx.
  18. ^  http://stackoverflow.com/a/10390305/1758733.
  19. ^  http://en.wikipedia.org/wiki/WinSXS.
  20. ^  Назаров К., Экстремально предвзятый взгляд на версионирование программных продуктов, http://nastachku.ru/lectures#lecture_178.
  21.  Oracle, Linker and libraries Guide, http://docs.oracle.com/cd/E19683-01/817-1983/index.html.
  22.  Руководство новичка по эксплуатации компоновщика, http://habrahabr.ru/post/150327/.
Теги:
Хабы:
Всего голосов 50: ↑47 и ↓3+44
Комментарии19

Публикации

Истории

Ближайшие события

15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань