Когда линковщик предаёт: как одинаковые символы из разных библиотек ломают ваше приложение

от автора

При линковке приложения с двумя статическими библиотеками, в которых определён один и тот же символ, возникает классическая и потенциально фатальная проблема — двойное определение символа. Вроде бы всё просто: multiple definition — ошибка, надо переименовать. Но не тут-то было.

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

Исходники: два public-файла и одна общая зависимость.

// pri.h int pri_foo(int);  // pri.c #include "pri.h" int pri_foo(int a) { return a * 10; }  // pub_foo.h int pub_foo(int);  // pub_foo.c #include "pub_foo.h" #include "pri.h" int pub_foo(int a) { return pri_foo(a + 11); }  // pub_boo.h int pub_boo(int);  // pub_boo.c #include "pub_boo.h" #include "pri.h" int pub_boo(int a) { return pri_foo(a + 22); } 

Собираем и смотрим символы.

gcc -c pri.c -o pri.o gcc -c pub_foo.c -o pub_foo.o gcc -c pub_boo.c -o pub_boo.o nm *.o ======================================= pri.o:       T pri_foo pub_foo.o:   U pri_foo, T pub_foo pub_boo.o:   U pri_foo, T pub_boo

Ничего необычного: pri_foo глобальный, вызывается из обоих public-файлов.

Собираем библиотеки с дублированием pri.o .

ar rcs libpub_foo.a pub_foo.o pri.o ar rcs libpub_boo.a pub_boo.o pri.o nm *.a ======================================= libpub_boo.a: pub_boo.o:   U pri_foo, T pub_boo pri.o:       T pri_foo libpub_foo.a: pub_foo.o:   U pri_foo, T pub_foo pri.o:       T pri_foo

В обеих статических библиотеках теперь содержится реализация pri_foo. Казалось бы — жди конфликтов при линковке.

// main.c #include "pub_foo.h" #include "pub_boo.h" #include <stdio.h>  int main() {     printf("%d\n", pub_foo(0));     printf("%d\n", pub_boo(0)); }
gcc main.c -L. -lpub_foo -lpub_boo -o test ./test ======================================= 110 220

…И это неверно. Хотя, как говорится, ответ вроде бы верный.

Почему? Потому что линковщик ld просто берёт первую встретившуюся реализацию pri_foo, удовлетворяет U, и вторую выбрасывает без предупреждений.

Порядок флагов -lpub_foo -lpub_boo определяет, какая версия pri_foo попадёт в бинарь, другая в «забвение».

А если pri_fooбудет иметь две разные реализации в каждой библиотеке?

// pri_for_foo.c #include "pri.h" int pri_foo(int a) { return a * 1000; }  // pri_for_boo.c #include "pri.h" int pri_foo(int a) { return a * 100; }

Собираем по той же схеме:

gcc -c pri_for_foo.c pub_foo.c gcc -c pri_for_boo.c pub_boo.c ar rcs libpub_foo.a pub_foo.o pri_for_foo.o ar rcs libpub_boo.a pub_boo.o pri_for_boo.o nm *.a ======================================= libpub_boo.a: pub_boo.o:       U pri_foo, T pub_boo pri_for_foo.o:   T pri_foo libpub_foo.a: pub_foo.o:       U pri_foo, T pub_foo pri_for_boo.o:   T pri_foo

Ну и чего мы ждём от тестового приложения?
11 * 1000 = 11000
22 * 100 = 2200

И что же мы увидим?

gcc main.c -L. -lpub_boo -lpub_foo -o test && ./test # Вывод: 1100, 2200

Как видно, теперь ответ неверный. Могу показать фокус.

gcc main.c -L. -lpub_foo -lpub_boo -o test && ./test # Вывод: 11000, 22000

Катастрофа: результат зависит от порядка флагов линковки. Это — полноценное неопределённое поведение. В больших проектах с зависимостями это приведёт к краху, отладке в ночи и душевным травмам.

И если вам кажется что с вами этого произойти не может, то напомню что любая не static функция из .c файла попадает в список внешних символов. Или, например, ваша библиотека статически линкует универсальные модули, и если разные версии этих модулей встретятся при линковке конечного приложения, то вы в ловушке.

Можно ли найти решение в динамической линковке? Определённо, но контроль и версионность символов в них отдельная тема.

Можно ли решить проблему в нашем примере? Да, но для этого понадобится два дополнительных шага при сборке. По сути необходимо разрешить символ pri_fooна стадии сборки самой библиотеки и скрыть символ pri_foo. Следите за руками!

Берём наши существующие объектные файл и объединяем их в один .o

> ld -r pri_for_boo.o pub_boo.o -o boo_combo.o > ld -r pri_for_foo.o pub_foo.o -o foo_combo.o nm foo_combo.o boo_combo.o ======================================= boo_combo.o:   T pri_foo, T pub_boo foo_combo.o:   T pri_foo, T pub_foo

Внешняя зависимость U pri_fooушла, теперь с чистой совестью символ pri_fooможно делать локальным.

objcopy boo_combo.o --localize-symbol pri_foo objcopy foo_combo.o --localize-symbol pri_foo nm foo_combo.o boo_combo.o ======================================= boo_combo.o:   t pri_foo, T pub_boo foo_combo.o:   t pri_foo, T pub_foo

Поменялось не многое, но по сути мы сделали символ pri_foo почти static. Почему почти? Потому что он всё ещё тут и удалить его не получится. Красивая картинка, когда nm показывает ТОЛЬКО публичное API библиотеки, не выходит. И удалить символ полностью мне пока не удалось. Если есть идеи, буду рад почитать в комментариях!

Однако что же будет теперь с выводом приложения?

> ar rcs libpub_foo.a foo_combo.o; ar rcs libpub_boo.a boo_combo.o > gcc main.c -L. -lpub_foo -lpub_boo -o test > ./test 11000 2200 > gcc main.c -L. -lpub_boo -lpub_foo -o test > ./test 11000 2200

Порядок линковки не важен, всё на месте.

Мораль: разработчику Си-библиотек приходится контролировать не только публичное API библиотеки, но и следить, чтобы приватное не просачивалось в глобальный namespace. Также большим плюсом считаю, что дёрнуть приватную функцию из библиотеки, просто угадав прототип, уже не выйдет.


ссылка на оригинал статьи https://habr.com/ru/articles/902448/