Записная книжка     О блоге     Архив записей     Лента

Чтобы самому не забыть и другим рассказать

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

В статье речь пойдет о высокоуровневом взгляде на компоновку. Где ищутся разделяемые библиотеки на 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. Если при увеличении минорной версии совместимость сохранили, то достаточно сделать соответствующий симлинк:

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 (ссылка на хранилище в гитхабе ). Чтобы собрать всю систему достаточно перейти в корень папки и вызвать ./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], но кажется, всё заглохло. В Solaris она есть [10][11], но сама Solaris перестала развиваться.

Одним из возможных вариантов является версионирование самих символов [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 с параметром:

$ ./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

- Эй, Дарт! Наша 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 зависит от library.dll, но и tools/auxiliary.bin зависит от нее же. При этом id библиотеки = @executable_path/library.dll, и оба бинарника были просто с ней скомпонованы. Тогда при запуске auxiliary.bin загрузчик будет искать /path/to/tools/library.dll и естественно не найдет! Конечно можно ручками после компоновки подправить tools/auxiliary.bin или кинуть мягкую ссылку, но опять неудобства!

Еще лучше проблема проявляет себя, когда речь заходит о подключаемых модулях (plugins):

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’ы можно так:<pre>

$ 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

На iOS всё так же.

Как видно из примера, 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, но все использовали разные версии.

C++

До сих пор сказанное было справедливо для С. В случае с С++ есть особенности.

Есть несколько вариантов:

  1. version экспортирует только шаблоны, т.е. никаких сгенерированных классов наружу не торчит;
  2. version экспортирует шаблоны, а также некоторые классы, сгенерированные с помощью этих шаблонов.

Сначала второй случай. Проблема здесь заключается в том, что когда liba включит version-1.0.h

// ...
typedef version::list<version::window> windows_list;
// ...

к себе в исходный текст, то дальнейшее развитие событий зависит от того, как оформлен шаблон version::list. Если он – header-only, то всё плохо:

  1. в самой version.dll будет присутствовать сгенерированный класс version::list<version::window>;
  2. когда будет компилироваться сама библиотека а, то в той единице трансляции, в которую был включен version.h, также будет сгенерирован класс version::list<version::window>. Он будет скомпилирован, в лучшем случае бинарно совместим с классом в version.dll, и все ссылки на него будут не undefined (что заставило бы загрузчик искать эти символы во время загрузки), а определены и указывать на сущности внутри а (в терминах nm – T либо t).

Даже если класс version::list<version::window> и был версионирован в version.dll, он окажется в непонятном состоянии в liba. (Тут, кстати, стоит отметить, что именно по этой причине некоторые люди используют AddRef/Release семантику для экспортируемых классов, а также, не экспортируют stl-классы: нагенерированные классы внутри самой библиотеки и ее пользователи отличаются бинарно даже в случае Debug/Release).

Чтобы решить эту проблему для C++03 и выше, шаблон надо разделить на объявление и определение: version_list.h, version_list_impl.h. Внутри самой библиотеки в файле реализации version_windows_list.cpp будет примерно так:

#include "version_windows_list.hpp"

// подключаем тело шаблона
#include "version_list_impl.h"

// явно инстанцируем шаблон
template class version::list<version::window>;

Теперь, при сборке самой version.dll будет использован version.script для символов vesion::list<version::window> внутри самой библиотеки. liba и другие при компиляции не получат тело шаблона version::list, что заставит компоновщик требовать его при связывании. Таким образом, все получат в зависимостях версионированный шаблонный класс. Техника разделения шаблона описана тут.

Начиная с Си++11 можно заставить компилятор не инстанцировать шаблон даже для хедер-онли шаблонов с помощью extern. Например, для std::unique_ptr:

// MyTypePtr.hpp
#include <memory>

namespace mylib
{

class MyType;

} // namespace mylib

extern template class std::unique_ptr<mylib::MyType>;

namespace mylib
{

using MyTypePtr = std::unique_ptr<mylib::MyType>;

} // namespace mylib


// MyTypePtr.cpp

#include "MyTypePtr.hpp"

#include "MyType.hpp"

template class std::unique_ptr<mylib::MyType>;

Такая техника позволит экспортировать даже классы, сгенеренные из хедер-онли шаблонов, и применить для этих классов version.script. Стоит отметить, что extern уже давно использовался как расширение Си++ в msvc: http://support.microsoft.com/kb/168958/en-us.

К сожалению, с помощью extern не получится экспортировать контейнер перемещаемых объектов: http://stackoverflow.com/questions/22381685/extern-template-class-stdcontainer-of-movable-objects.

Теперь вернемся к первому случаю, когда экспортируются только шаблоны. Как видим, эти шаблоны должны быть грамотно оформлены. С точки зрения version, это проблема других, как они используют эти шаблоны. Если представляют их наружу – это первый случай, только с позиции уже другой библиотеки. Если используют для внутренних нужд – то с помощью того же version.script они должны быть помечены как local.

Также на счет С++ ABI.

В MacOS и в Linux’е, c++filt входит в состав системы. Также есть стандартный ABI. Есть, конечно, проблемы связанные с тем, что программисты соответствующие инструменты неправильно используют, ну так от этого и COM не защищает.

Заключение

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

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

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

Ссылки

  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.  Oracle, Linker and libraries Guide, http://docs.oracle.com/cd/E19683-01/817-1983/index.html.
  21. Руководство новичка по эксплуатации компоновщика.
  22. semver.org