Ранее я рассматривал ODR-violation в общих чертах и предупреждал о том, что может произойти, если случайно выбрать не то имя переменной, структуры или функции в C++. В этой же части я бы хотел продемострировать более изящный пример, не требующий приложения никаких усилий по написанию кривого кода. Достаточно просто иметь кривой код в ваших third-party зависимостях.
Недавно я имел дело со странным баг-репортом:
Во внутренем репозитории с пакетами обновилcя пакет с библиотекой gtest -- известная уважаемая библиотека для написания самых разных тестов на C++. И в результате обновления некоторые тесты в конечных приложениях стали внезапно падать.
Падать они стали по-разному. У одних стали валиться проверяющие ассерты. У других же все работало, проверки проходили, но ctest рапортовал что тестирующий процесс вышел с ненулевым кодом возврата.
Если с первыми происходило что-то совершенно невразумительное, то со вторыми можно было работать.
Запускаем тест вручную: 5/5 passed. Segmentation Fault. О!
Запустив тест под отладчиком и выведя бэктрейс, я получил нечто следующего вида
Program received signal SIGSEGV, Segmentation fault.
0x00007ffff7e123fe in __GI___libc_free (mem=0x55555556a) at ./malloc/malloc.c:3368
3368 ./malloc/malloc.c: No such file or directory.
(gdb) bt
#0 0x00007ffff7e123fe in __GI___libc_free (mem=0x55555556a) at ./malloc/malloc.c:3368
#1 0x00007ffff7fb75ea in std::vector<std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >, std::allocator<std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > > >::~vector() () from ./libgtest.so
#2 0x00007ffff7db2a56 in __cxa_finalize (d=0x7ffff7fb5090) at ./stdlib/cxa_finalize.c:83
#3 0x00007ffff7fb2367 in __do_global_dtors_aux () from ./libgmock.so
__do_global_dtors_aux () from ./libgmock.so
что-то страшное и одновременно прекрасное произошло, осталось лишь понять что именно.
Я прошелся по тестам: они были сгруппированы по отдельным исходникам и каждый из них собирался в свой исполняемый файл. Все тесты собирались одними и теми же параметрами компиляции: конфигурация задавалась в cmake тупо как
file(GLOB files "*_test.cpp")
foreach(file ${files})
add_test(...., ${file})
endforeach()
Я открыл исходники падающего и не падающего теста: падающий тест использовал gMock. А не падающий не использовал. Но при этом оба исполняемых файла были слинкованы с библиотекой libgmock.so
.
Посмотрим еще раз на фрагмент бэктрейса
std::allocator<char> > > >::~vector() () from ./libgtest.so
#2 0x00007ffff7db2a56 in __cxa_finalize (d=0x7ffff7fb5090) at ./stdlib/cxa_finalize.c:83
#3 0x00007ffff7fb2367 in __do_global_dtors_aux () from ./libgmock.so
Финализация глобальных объектов в libgmock как-то связана с деструктором глобальной переменной в libgtest.
Я открыл список изменений, что же там такое обновилось во внутреннем пакете с GTest...
Commit: ...
Produce shared libraries along with statics
И ровно две строчки, добавляющие в его CMakeLists.txt еще и динамические версии библиотек libgmock и libgtest.
Интересно. Я вышел в интернет с этим вопросом и обнаружил что-то очень похожее. Первым делом, глянув на дату issue, а также сверив даты коммитов во внутренней версии, я был глубоко разочаровам темпами обновления зависимостей (ведь на дворе был конец 2023 года, а issue датируется 2016). Но это уже совсем другая проблема C++...
Что же произошло на самом деле?
Фреймворк GoogleTest содержал две библиотеки
- gtest -- core библиотека со всеми причиндалами для автоматической регистрации тестов и легкого их запуска через макрос
RUN_ALL_TESTS
- gmock -- библиотека специально для mock тестирования
gmock линкуется с gtest. Конечный пользовательский исполняемый файл с тестами линкуется с обеими библиотеками.
Все нормально. Ничего криминального в этом нет.
Для поддержки всей красоты автоматической регистрации тестов и фикстур (вам как пользователю не нужно никуда складывать тестовые функции, вы их просто объявляете с помощью макросов) gtest очень активно полагается на глобальные переменные.
Проблемным объектом, чей деструктор приводил к падению, оказался как видно из бэктрейса вектор строк в libgtest.so
В исходниках gtest я обнаружил, что этот вектор -- глобальная переменная куда InitGoogleTest()
складывает распознанные аргументы командной строки. Просто глобальная перемменная, объявленная в компилируемом файле. Она не была в заголовочном файле. Всё вроде бы должно было быть хорошо... За одним исключением: она не была помечена static
и не была обернута в анонимный namespace.
И что? Ведь все же работало? Да, работало. Хитрость в том, как собирается библиотека gmock. Воспроизведем все пошагово.
Заведем свой gtest дома
// gtest.h
#pragma once
void initGoogleTest(int argc, char* argv[]);
void runTests();
// gtest.cpp
#include "gtest.h"
#include <vector>
#include <string>
#include <iostream>
// глобальная переменная, не static, как было в gtest
std::vector<std::string> g_args;
void runTests() {
// просто для демонстрации
std::cout << "run gtest\n";
for (const auto& arg : g_args) {
std::cout << arg << " ";
}
std::cout << "\n";
}
void initGoogleTest(int argc, char* argv[]) {
for (int i = 0; i < argc; ++i) {
g_args.push_back(argv[i]);
}
}
Соберем его в статическую библиотеку. Ведь именно статические библиотеки были изначально в пакете.
g++ -std=c++17 -fPIC -O2 -c gtest.cpp
ar rcs libgtest.a gtest.o
Добавим свой gmock
// gmock.h
#pragma once
void runMocks();
// gmock.cpp
#include "gmock.h"
#include "gtest.h" // gmock линкуется с gtest!
#include <iostream>
void runMocks() {
// для демонстрации
std::cout << "run Mocks:\n";
runTests();
}
Соберем его также в статическую библиотеку
g++ -std=c++17 -fPIC -O2 -c gmock.cpp
ar rcs libgmock.a gmock.o gtest.o # https://github.com/google/googletest/blob/d41f53ae7816863cc52bf3f357dff25597c58864/googlemock/make/Makefile#L89C1-L90C24
И начнем пользоваться
// main.cpp
#include "gtest.h"
#include "gmock.h"
int main(int argc, char* argv[]) {
initGoogleTest(argc, argv);
runMocks();
runTests();
return 0;
}
g++ -std=c++17 -O2 -o main main.cpp -L . -lgtest -lgmock
./main 1 2 3 4
run Mocks:
run gtest
./main 1 2 3 4
run gtest
./main 1 2 3 4
Должно быть очевидно, что прилинковав обе библиотеки gtest и gmock, мы уже как бы получили ODR-violation: gmock содержит в себе gtest, а значит у нас две копии глобальной переменной. Но всё работает: линкер выкинул одну из копий.
А теперь добавим динамические версии
g++ -shared -fPIC -o libgtest.so gtest.o
g++ -shared -fPIC -o libgmock.so gtest.o gmock.o
И пересоберем клиентский код
g++ -std=c++17 -O2 -o main main.cpp -L . -lgtest -lgmock
LD_LIBRARY_PATH=. ./main
run Mocks:
run gtest
./main
run gtest
./main
Segmentation fault (core dumped)
Ура! Падает!
Обе библиотеки имеют свою собсвенную версию глобальной переменной с одним и тем же неявно экспортируемым именем. Использоваться опять-таки будет только одна.
После загрузки библиотеки, после конструирования глобальной переменной, стандарт C++ требует зарегистрировать (например через __cxa_atexit
) функцию для вызова деструктора. У нас две библиотеки -- значит две функции будут вызваны. На одном и том же объекте. Double free. Конструктор, кстати, также вызывается дважды по одному и тому же адресу:
struct GArgs : std::vector<std::string> {
GArgs() {
std::cout << "Construct it: " << uintptr_t(this) << "\n";
}
};
GArgs g_args;
LD_LIBRARY_PATH=. ./main 1 2 3
Construct it: 140368928546992
Construct it: 140368928546992
run Mocks:
run gtest
./main 1 2 3
run gtest
./main 1 2 3
Segmentation fault (core dumped)
И это прекрасно. Хорошо. Теперь проблема ясна. Тесты, которые падали на ассертах, также страдали, но от других глобальных переменных. Остался последний вопрос: я упомянул, что все тесты собирались одинаково, но какие-то не падали -- те, что не использовали gmock. Но все равно с ним линковались.
Закомментируем использование нашего gmock
#include "gtest.h"
#include "gmock.h"
int main(int argc, char* argv[]) {
initGoogleTest(argc, argv);
// runMocks();
runTests();
return 0;
}
g++ -std=c++17 -O2 -o main main.cpp -L . -lgtest -lgmock
LD_LIBRARY_PATH=. ./main 1 2 3
Construct it: 140699707998384
run gtest
./main 1 2 3
О как! Конструктор вызвался только раз. Значит и деструктор будет вызван только раз. Все отлично. Но ведь мы же линковали... Современные линковщики достаточно умны чтоб не тащить то что не используется (что кстати иногда является проблемой, если у конструкторов в библиотеке есть побочные эффекты).
Если мы заставим gcc прилинковать gmock насильно
g++ -std=c++17 -O2 -o main main.cpp -Wl,--no-as-needed -L . -lgtest -lgmock
LD_LIBRARY_PATH=. ./main 1 2 3
Construct it: 139687276064944
Construct it: 139687276064944
run gtest
./main 1 2 3
Segmentation fault (core dumped)
Все будет, как и должно, сломано.
Подобный паттерн по созданию проблем оказывается невероятно распространенным! И не только в C++
LibA
статически влинковывается в LibB
и обе LibA
и LibB
влинковываются в BinC
. Самый частый кандидат на такую LibA
-- библиотеки менеджмента памяти.
Например, при сборке динамических библиотек в Rust и подключении их в другие Rust проекты практически всегда люди натыкаются на эту проблему: std
статически влинковывается и в библиотеку и в исполняемый файл.
Я также обнаружил подобные проблемы в AWS SDK:
Глобальный UniquePtr также двумя путями попадает в конечное приложение, потому в его деструктор воткнули зануление, чтоб не вызывать delete
дважды.