ABI-совместимость в C++: где ломается прод
Библиотеку пересобрали, приложение не тронули — и оно падает с SIGSEGV без единой ошибки компоновщика. Разбираем, почему ABI ломается тихо и что нельзя менять в публичном заголовке разделяемой библиотеки.

Классический сценарий: вы поставляете libcore.so, ваши клиенты собирают приложения поверх неё. Вы добавляете одно приватное поле в класс, пересобираете библиотеку, выкатываете минорное обновление. Компоновщик молчит, приложение линкуется. Через день клиент присылает стек с SIGSEGV где-то внутри деструктора — в коде, который вы не меняли. Это не UB на ровном месте, это нарушение ABI: старый бинарник рассчитывает на одну раскладку объекта в памяти, новая библиотека — на другую.
Здесь разбираем, что именно в публичном интерфейсе C++ считается частью ABI, какие изменения ломают совместимость тихо, а какие честно падают на этапе загрузки. Речь о разделяемых библиотеках (.so/.dll) на GCC и Clang с Itanium C++ ABI — на MSVC своя история, отдельные оговорки по ходу текста. Инвалидация уже развёрнутых бинарников на стороне клиента — не наша тема, мы про то, что нельзя менять в исходниках.
Что такое ABI и почему API-совместимости мало
API-совместимость означает, что код, который компилировался с прошлой версией, скомпилируется и с новой. ABI*-совместимость — что бинарник, собранный со старыми заголовками, продолжит работать со свежей библиотекой без перекомпиляции. Это разные гарантии, и вторая куда жёстче.
ABI фиксирует то, что заголовок не показывает явно, но что компилятор зашил в машинный код клиента на этапе сборки:
- размер и выравнивание каждого типа, который клиент создаёт на стеке или встраивает в свои структуры;
- смещения полей внутри объекта — по ним генерируется
obj.field; - раскладку таблицы виртуальных функций и индексы в ней;
- искажённые (mangled) имена символов функций и методов;
- соглашения о вызовах и способ передачи параметров.
Всё это компилятор клиента фиксирует один раз при сборке. Пересобрав только библиотеку, вы меняете свою половину контракта, а клиентская половина остаётся прежней. Расхождение — и есть источник тихих падений.
Компоновщик проверяет только имена символов. Он не знает, что размер вашего класса вырос на восемь байт — эту цифру клиент уже вкомпилировал в свой new.Изменения, которые ломают ABI
Добавление или удаление нестатических полей
Самое частое и самое коварное. Добавили std::string cache_; в публичный класс — размер объекта вырос, сместились все поля после него. Клиент выделяет память под старый размер, а конструктор библиотеки пишет за его пределы. Или наоборот: клиент читает поле по старому смещению и получает мусор.
Порядок полей тоже часть ABI. Поменяли местами два int — смещения разъехались, хотя размер класса не изменился.
Изменение виртуальных функций
Виртуальные вызовы идут через vtable по фиксированному индексу. Любое из этих действий сдвигает индексы:
- добавление новой виртуальной функции (кроме как в самый конец, и то с оговорками);
- удаление виртуальной функции;
- изменение порядка объявления виртуальных функций;
- превращение обычного метода в виртуальный и наоборот.
После сдвига клиент вызывает по старому индексу — и попадает не в ту функцию. Иногда это тихая порча данных, иногда сразу SIGSEGV.
Изменение сигнатуры или квалификаторов
Имена в C++ искажаются с учётом типов параметров, const, ссылок и namespace. Меняя сигнатуру, вы меняете mangled-имя — старый символ исчезает. Здесь ABI ломается честно: при загрузке вы получите undefined symbol, а не тихую порчу.
// было — mangled как _ZN4Core4sendERKNSt7__cxx1112basic_stringIcEE (схематично)
void send(const std::string& msg);
// стало — уже другой символ, старый бинарник его не найдёт
void send(std::string_view msg);Добавление const к методу, изменение типа возврата (кроме ковариантного), даже перенос класса в другой namespace — всё меняет искажённое имя.
Inline-функции с изменившимся телом
Тело inline-функции и определение шаблона попадают в бинарник клиента при сборке. Изменили тело inline-геттера в заголовке — у клиента остаётся старая копия. Пока клиент не пересоберётся, он выполняет прежнюю логику. Формально символ на месте, а поведение разное — отсюда трудноуловимые расхождения между модулями одной программы.
Что менять безопасно
| Изменение | ABI-совместимо | Условие |
|---|---|---|
| Добавить невиртуальный метод | Да | Не трогает раскладку объекта |
| Добавить статическое поле | Да | Живёт вне объекта |
| Добавить нестатическое поле | Нет | Меняет размер и смещения |
| Добавить виртуальную функцию | Рискованно | Только в конец, если нет наследников вне библиотеки |
| Изменить тело невиртуального метода в .cpp | Да | Тело не в заголовке |
| Изменить тело inline/шаблона в заголовке | Нет | Тело вкомпилировано у клиента |
| Добавить параметр со значением по умолчанию | Нет | Меняет mangled-имя |
Как защищаться: приёмы, которые реально работают
Прячьте состояние за указателем (PImpl)
Главный инструмент. Класс в публичном заголовке содержит один указатель на приватную реализацию. Тогда добавление полей в реализацию не трогает размер публичного объекта — он всегда размером с указатель.
// widget.hpp — публичный заголовок, стабильный ABI
#include <memory>
class Widget {
public:
Widget();
~Widget();
void draw();
int width() const;
private:
struct Impl; // объявление, не определение
std::unique_ptr<Impl> impl_; // единственное поле — указатель
};
// widget.cpp — тут можно менять что угодно
#include "widget.hpp"
struct Widget::Impl {
int width = 0;
int height = 0;
// добавляйте поля — размер Widget не изменится
};
Widget::Widget() : impl_(std::make_unique<Impl>()) {}
Widget::~Widget() = default; // должен быть в .cpp, где Impl полный тип
void Widget::draw() { /* ... */ }
int Widget::width() const { return impl_->width; }Цена: лишняя аллокация на объект, потеря inline-оптимизаций, дополнительный уровень косвенности при каждом обращении к полю. Для горячего пути в цикле это ощутимо; для объектов уровня подсистемы — незаметно.
Не наследуйтесь от публичных классов через границу библиотеки
Как только клиент наследуется от вашего класса с виртуальными функциями, он завязывается на конкретную раскладку vtable. Дальше вы не сможете тронуть виртуальный интерфейс. Отдавайте наружу чистые абстрактные интерфейсы с фабриками, а не конкретные классы для наследования.
Держите тела в .cpp, а не в заголовке
Каждая inline-функция и каждый видимый шаблон — это код, скопированный клиенту. Чем меньше тел в публичном заголовке, тем меньше связей, которые нельзя изменить без перекомпиляции клиента.
Версионируйте символы, если поддерживаете старые бинарники
GNU-компоновщик умеет version scripts: несколько реализаций одной функции под разными версиями символа. Старые бинарники разрешаются в старую версию, новые — в новую. Механизм рабочий, но заметно усложняет сборку — синтаксис version script смотрите в документации ld, не полагайтесь на память.
Как ловить нарушения до прода
Ревью на глаз не масштабируется. Есть инструменты, которые сравнивают ABI двух сборок библиотеки:
- abidiff из пакета
libabigail— сравнивает две.soи печатает список ABI-ломающих изменений: сдвинутые поля, изменённые vtable, пропавшие символы. Ставьте в CI на сравнение с прошлым релизом. - abi-compliance-checker — генерирует HTML-отчёт о совместимости по паре версий заголовков и библиотек.
Точные флаги и формат вызова этих утилит уточняйте в их документации — версии различаются. Ключевая идея: сделайте проверку ABI частью пайплайна, где мажорный релиз имеет право ломать совместимость, а минорный и патч — нет.
Граничные случаи, о которых забывают
- Смена компилятора или флага
-std. GCC до 5.x и после различались в ABIstd::stringиstd::list(переключатель_GLIBCXX_USE_CXX11_ABI). Смешивание объектников, собранных с разными значениями, даёт линковку без ошибок и падения в рантайме. - Типы стандартной библиотеки в публичном интерфейсе. Если в сигнатуре торчит
std::vectorилиstd::string, вы завязали свой ABI ещё и на ABI конкретной реализации STL. Для стабильного C++-интерфейса это дополнительный риск; библиотеки с жёсткой гарантией совместимости часто отдают наружу C-совместимый слой. - MSVC. Там нет единого стабильного C++ ABI между версиями компилятора вплоть до недавнего времени; правила debug/release runtime и
/MDvs/MTдобавляют свои грабли. Всё вышесказанное про Itanium ABI напрямую туда не переносится.
Что делать дальше
Разделите заголовки на публичные (с гарантией ABI) и внутренние. Для публичных введите правило: любой класс, который клиент создаёт или наследует, идёт через PImpl или абстрактный интерфейс. Поставьте abidiff в CI на сравнение с последним тегом и сделайте красный отчёт блокирующим для минорных релизов. И зафиксируйте в политике версий, что смена мажора — единственное место, где ABI разрешено ломать.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: Itanium C++ ABI specification, libabigail — ABI analysis tools (GNU)
Частые вопросы
Почему компоновщик не поймал добавленное поле в классе?
Достаточно ли PImpl, чтобы гарантировать ABI-совместимость?
А если клиент всё равно всегда пересобирается вместе с библиотекой?
У меня MSVC, а не GCC — правила те же?
Что делать, если abidiff показал изменение, но я уверен, что оно безопасно?
Можно ли добавлять виртуальную функцию в конец класса без последствий?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.