Линкуем C++ либы без конфликтов
Порой в одном приложении нужны две версии одной и той же библиотеки - и это приводит к неожиданным конфликтам символов при линковке. Расскажу, как с этим справиться.
Постановка задачи
Основное приложение main_app линкуется со статической библиотекой libstatic_v2.a. Для новой фичи подключить стороннюю библиотеку libdynamic.so, которая зависима от libshared_v1.so - по сути того же кода из libstatic_v2.a, но старой версии.
Понизить версию libstatic_v2.a нельзя - проект может сломаться, а чужую библиотеку переделывать никто не будет. При этом в интерфейсах libdynamic.so ничего из libshared_v1.so нет, так что две библиотеки, в теории, могут жить рядом.
Разберемся, что делать, если один и тот же символ реализован дважды, и линковщик “не понимает”, какую версию выбрать.
Демонстрационный hello_world
Демо лежит на Godbolt, где можно управлять поведением через cmake флаги. Весь код проекта на GitHub
Код заголовочных файлов libstatic_v2.a и libshared_v1.so идентичен, но у разных версий реализация отличается:
// header_v1.h header_v2.h
void print_version();
// main_v1.cpp
void print_version() {
std::cout << "1" << std::endl;
}
// main_v2.cpp
void print_version() {
std::cout << "2" << std::endl;
}
В libdynamic.so реализована обертка над print_version из libshared_v1.so:
#include "header_v1.h"
extern void print_version();
void call_print_from_v1() {
std::cout << "Call to v1: ";
print_version();
}
В main_app находятся два вызова из статической и динамической библиотек:
#include "header_v2.h"
extern void print_version(); // from static lib_v2
extern void call_print_from_v1(); // from dynamic libdyn
int main() {
std::cout << "Static lib_v2 prints: ";
print_version();
std::cout << "Dynamic libdyn->lib_v1 prints: ";
call_print_from_v1();
}
Собираем все через Cmake:
add_library(lib_v1_shared SHARED main_v1.cpp)
set_target_properties(lib_v1_shared PROPERTIES OUTPUT_NAME shared_v1)
add_library(lib_v2_static STATIC main_v2.cpp)
set_target_properties(lib_v2_static PROPERTIES OUTPUT_NAME static_v2)
add_library(lib_dyn SHARED lib_dyn.cpp)
set_target_properties(lib_dyn PROPERTIES OUTPUT_NAME dynamic)
target_link_libraries(lib_dyn PRIVATE lib_v1_shared)
add_executable(main_app main.cpp)
target_link_libraries(main_app PRIVATE lib_v2_static lib_dyn)
Конфликт
После запуска ожидаем увидеть корректную работу:
Static lib_v2 prints: 2
Dynamic libdyn->lib_v1 prints: Call to v1: 2 // <----- ожидали 1
Линковщик берёт первый глобальный символ print_version из статической библиотеки и отдаёт его libdynamic.so.
Скрываем символы
Решение заключается в том, чтобы символы из статической библиотеки убрать из глобальной области видимости. Для этого есть два способа.
Способ первый: exclude-libs
Указываем во время компановки проекта main_app, что нужно скрыть символы из зависимостей:
# всех библиотек
target_link_options(main_app PRIVATE "-Wl,--exclude-libs,ALL")
# конкретной библиотеки
target_link_options(main_app PRIVATE "-Wl,--exclude-libs,libstatic_v2.a")
Способ работает только в GNU.
Способ второй: visibility=hidden
Этот способ универсален, указывается во время компиляции статической библиотеки:
target_compile_options(lib_v2_static PRIVATE -fvisibility=hidden -fvisibility-inlines-hidden)
Проверка
После реализации одного из способов выше обе версии print_version резолвятся корректно:
Static lib_v2 prints: 2
Dynamic libdyn->lib_v1 prints: Call to v1: 1
Убедимся в корректности исправлений с помощью readelf, nm и LD_DEBUG.
Посмотрим, что libdynamic.so будет пытаться резолвить символ:
readelf --dyn-syms lib/libdynamic.so | grep print_version
6: 0000000000000000 0 FUNC GLOBAL DEFAULT UND _Z13print_versionv
# или
nm lib/libdynamic.so | grep print_version
U _Z13print_versionv
Флаг UND (U) говорит о том, что реализация этой функции будет взята из внешнего источника.
До добавления исправляющих флагов сборки/компоновки символ print_version должен быть глобальным:
readelf --dyn-syms main_app | grep print_version
17: 0000000000001300 40 FUNC GLOBAL DEFAULT 15 _Z13print_versionv
# или
nm main_app | grep print_version
0000000000001300 T _Z13print_versionv
LD_DEBUG=bindings main_app 2>&1 | grep print_version
944267: binding file libdynamic.so [0] to main_app [0]: normal symbol `_Z13print_versionv
В выводе первых двух команд видим флаг GLOBAL (T), значащий, что символ глобальный. Третья команда показывает, что символ print_version для libdynamic.so берется из main_app.
Посмотрим на вывод аналогичных команд для версии проекта без конфликта:
readelf --dyn-syms main_app | grep print_version
# пусто
nm main_app | grep print_version
00000000000012a0 t _Z13print_versionv
LD_DEBUG=bindings main_app 2>&1 | grep print_version
945221: binding file libdynamic.so [0] to libshared_v1.so [0]: normal symbol `_Z13print_versionv'
Теперь флаг t в nm показывает, что символ локальный. Символ print_version для libdynamic.so берется из libshared_v1.so.
Символы шаблонов
С обычными функциями проблема решена. В C++ в интерфейсах библиотек могут быть шаблоны, которые добавляют новый уровень боли.
Шаблоны в hello_world
Добавим шаблонную функцию с одинаковой сигнатурой, но разной реализацией в заголовочные файлы libstatic_v2.a и libshared_v1.so. Добавим обертку в libdynamic.so и соответствующие вызовы в main_app:
// header_v1.h
template<typename T> T multiply(T a, T b) { return a * b; }
// header_v2.h
template<typename T> T multiply(T a, T b) { return a * b + 200000; }
// libdynamic.so
int call_multiply_from_v1(int a, int b) {
std::cout << "Call to v1: ";
return multiply<int>(a, b);
}
// mani_app
int call_multiply_from_v1(int,int);
int main() {
// ...
std::cout << "multiply<int>() direct from v2: " << multiply<int>(3,4) << std::endl;
std::cout << "call_multiply_from_v1: " << call_multiply_from_v1(3,4) << std::endl;
}
Конфликт
Собираем проект и ожидаем корректный вывод:
multiply<int>() direct from v2: 200012
call_multiply_from_v1: Call to v1: 200012 // <----- ожидали 12
Наличие флагов -fvisibility=hidden, -Wl,--exclude-libs,ALL ситуаюцию не исправляет.
Попробуем закоментировать первую строку с вызовом multiply<int>(3,4):
call_multiply_from_v1: Call to v1: 12
Удивительно, но теперь получили верный ответ.
Разгадка кроется в месте инстанцирования шаблонов: шаблоны истанцируются в тех единицах трасляции, где были вызваны. Т.е. вызов multiply<int>(3,4) инстанцирует шаблон в main_app, создавая глобальный символ. Линковщик берёт глобальный символ multiply<int> и подставляет его libdynamic.so, хотя в этой библиотеке он есть:
nm libdynamic.so | grep multiply
00000000000011f0 W _Z8multiplyIiET_S0_S0_
nm main_app | grep mult
00000000000012f0 W _Z8multiplyIiET_S0_S0_
Символ помечен W - weak. Так как оба типа равнозначны, линковщик берет символ из main_app.
Скрываем символы v2
Решение заключается в том, чтобы символы из main_app убрать из глобальной области видимости.
В этот раз нам нужно будет создать дополнительный файл со списком глобальных и локальных символов и добавить в флаги компановщика:
# main_app.map
{
global:
main;
local:
*;
};
# cmake main_app
target_link_options(main_app PRIVATE "-Wl,--version-script=${CMAKE_SOURCE_DIR}/main_app.map")
Метод работает только для GNU.
Проверка
После подключения к проекту main_app.map получим полностью рабочий пример. При этом скрытие символов от статической библиотеки больше не нужно.
Static lib_v2 prints: 2
Dynamic libdyn->lib_v1 prints: Call to v1: 1
multiply<int>() direct from v2: 200012
call_multiply_from_v1: Call to v1: 12
Проверим, что все действительно так:
LD_DEBUG=bindings main_app 2>&1 | grep multiply
# до исправления
944647: binding file libdynamic.so [0] to main_app [0]: normal symbol `_Z8multiplyIiET_S0_S0_'
# после
944739: binding file libdynamic.so [0] to libdynamic.so [0]: normal symbol `_Z8multiplyIiET_S0_S0_'
Полный пример и сборка
Демо-проект лежит на GitHub.
Сборка со всеми опциями:
cmake -S . -B build -DWITH_VISIBILITY_HIDDEN=ON/OFF \
-DWITH_EXCLUDE_LIB2=ON/OFF \
-DWITH_VERSION_SCRIPT=ON/OFF \
-DCOMMENT_TEMPLATE_STATIC_CALL=0/1
cmake --build build
./build/bin/main_app
Вывод
Линковка - дело тонкое. Я уже дважды сталкивался с её подводными камнями: сначала - с настройкой rpath для плагина Wireshark, теперь - с управлением видимостью символов через visibility, --exclude-libs и version scripts. Оба опыта показали, что конфликты получить крайне легко.