[Из песочницы] Про компоновку, dependency hell и обратную совместимость

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

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

88712f3724d556bdba1f6b929a47ad4d.pngmain.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); создание перемещаемых приложений: где динамический загрузчик будет искать version-0.?.dll и bar.dll для приложения из предыдущего пункта? Найдёт ли он зависимости main.exe, если тот будет перемещён в другую папку? Как нужно собрать main.exe, чтобы зависимости искались относительно исполняемого файла? 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.dllversion-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]: список каталогов в секции DT_RPATH, которая жёстко прописана в исполняемом файле. Поддерживается большинством *nix-систем. Игнорируется, если присутствует секция DT_RUNPATH; LD_LIBRARY_PATH — переменная окружения, также содержит список каталогов; DT_RUNPATH — тоже самое, что и DT_RPATH, только просматривается после LD_LIBRARY_PATH. Поддерживается только на самых свежих Unix-подобных системах; /etc/ld.so.conf — файл настроек динамического загрузчика ld.so, который содержит список папок с библиотеками; жёстко зашитые пути — обычно /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.

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

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

© Habrahabr.ru