Подробно об ABI для работы с C++

от автора

Двоичный интерфейс приложений, чаще именуемый просто ABI — это концепция, которая кажется знакомой и незнакомой одновременно. В каком смысле знакомой? Об ABI часто говорят в контексте устранения неисправностей, упоминают в статьях. Иногда даже приходится решать проблемы с совместимостью, которые провоцирует этот интерфейс. А в каком смысле незнакомый? Дело в том, что, если кто-то попросит вас описать, что такое ABI — то вы обнаружите, что понимаете, о чём речь, но чётко сформулировать ответ на этот вопрос сложновато. В конце концов, можно ограничиться формулировкой, указанной в Википедии: «набор соглашений для доступа приложения к операционной системе и другим низкоуровневым сервисам, спроектированный для переносимости исполняемого кода между машинами, имеющими совместимые ABI». Возникает ли проблема с такой формулировкой? Нет, в качестве общего описания этого вполне достаточно. Но оно может казаться немного поверхностным.

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

ЦП и ОС

В конечном итоге, готовый исполняемый файл выполняется в конкретной операционной системе, работающей на определённом процессоре. Наборы инструкций отличаются от процессора к процессору, и это неизбежно приводит к несовместимости на уровне двоичного кода. Например, программы для архитектуры ARM не могут работать непосредственно на процессорах x64 (если, конечно, не задействуется та или иная технология виртуализации). А что, если наборы инструкций совместимы? Например, процессоры x64 совместимы с набором инструкций x86. Означает ли это, что программа для x86 определённо станет работать в операционной системе, рассчитанной на x64? Здесь уже многое зависит от операционной системы, а именно, от таких аспектов, как формат объектных файловпредставление данныхсоглашение о вызове функций и библиотека среды выполнения. Эти моменты можно расценивать как нормативы ABI, действующие на уровне операционной системы. Библиотеку среды выполнения мы отдельно обсудим ниже, ей в этой статье посвящён целый раздел. А прямо сейчас разберём три первых пункта, взяв в качестве примера платформу x64.

x64, x86-64, x86_64, AMD64 и Intel 64 — всё это разные варианты 64-битной версии набора инструкций x86.

На платформе x64 наиболее распространены два основных ABI:

  • Windows x64 ABI для 64-битных версий ОС Windows

  • x86-64 System V ABI для 64-битных дистрибутивов Linux и различных UNIX-подобных операционных систем

Вызов функции из динамической библиотеки можно рассматривать как следующую трёхчастную процедуру:

  • Разбираем динамическую библиотеку в соответствии с определённым форматом.

  • В результатах разбора ищем адрес функции, отталкиваясь от символьного имени.

  • Передаём параметры и вызываем саму функцию.

Формат объектных файлов

Как именно разбирать динамическую библиотеку? Здесь обращаем внимание на то, как ABI регулирует формат объектных файлов. Если вы хотите написать собственный линковщик, то готовый исполняемый файл должен соответствовать формальным требованиям, действующим на конкретной платформе. В Windows x64 используется формат исполняемых файлов PE32+, представляющий собой 64-битную версию PE32 (переносимый исполняемый 32-битный). В ABI System V ABI используется формат ELF (формат исполнимых и компонуемых файлов). Если вы пользуетесь библиотеками для парсинга, такими как pe-parse и elfio (или пишете собственные, если вам это интересно) при разборе конкретных исполняемых файлов, а затем получаете их символьные таблицы, то за этой работой можете выявить, какие адреса соответствуют функциям с конкретными именами.

Представление данных

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

Допустим, я компилирую следующий файл в динамическую библиотеку:

struct X{    int a;    int b;};int foo(X x){    return x.a + x.b;}

Далее, при последующем обновлении версии, содержимое структуры меняется, и вот какой вид принимает определение структуры в пользовательском коде:

struct X{    int a;    int b;    int c;};

Затем код всё равно пытается связать динамическую библиотеку, скомпилированную из старой версии кода, и вызывает её функцию:

int main(){    int n = foo({1, 2, 3});    printf("%d\n", n);}

Будет ли эта операция успешной? Разумеется, нет. Ошибку такого типа можно считать так называемым «нарушением ODR» (правила одного определения). Другие примеры такого нарушения будут рассмотрены в следующих разделах.

В представленной выше ситуации нарушение ODR происходит из-за того, что пользователь активно меняет код. Но что, если я так не поступаю и могу гарантировать стабильность компоновки структуры? Это аспект гарантируется представлением данных на уровне ABI. Например, здесь указывается как размер базовых типов, так и их выравнивание. В Windows x64 длина long составляет 32 разряда, а в System V длина long составляет 64 разряда. Также здесь указывается размер и выравнивание структур (struct), объединений (union) и т.д.

Обратите внимание: в стандарте языка C по-прежнему нет спецификации ABI. Что касается ABI System V, он написан преимущественно с использованием терминологии языка C и его концепций, поэтому можно сказать, что мы предоставляем ABI для языка C. В ABI для архитектуры Windows x64 ABI не проводится достаточно чёткой границы между C и C++.

Соглашение о вызове функций

Далее переходим к этапу передачи параметров функций. Как известно, функция — это просто фрагмент двоичных данных. Выполнение функции — это просто переход к адресу входа в функцию,выполнение этого участка кода и, по окончании этой работы — переход обратно. Передать параметры — означает просто найти место, где будут храниться данные, так, чтобы к этому месту можно было обращаться для извлечения данных как до вызова, так и после него. Какие локации можно выбрать? В принципе, существует четыре основных варианта:

  • global (глобальные переменные)

  • heap (куча)

  • register (регистры)

  • stack (стек)

Кажется, что использование глобальных переменных для передачи параметров – это какая-то магия. Но на практике, когда пишешь код, многие параметры требуется передавать неоднократно, например, в составе config, и они меняются в глобальных переменных. Но, в то же время ясно, что не все параметры пригодны для передачи в глобальных переменных, и требуется ещё внимательнее учитывать безопасность потоков.

Использование кучи для передачи параметров – на первый взгляд, также что-то фантастичное. Но, на самом деле, в C++20 используются бесстековые корутины, которые хранят свои состояния (параметры функций, локальные переменные) именно в куче. Но, что касается обычных вызовов функций, если при каждой передаче параметров требуется динамически выделять память, то ситуация действительно кажется несколько экстравагантной.

Таким образом, обычно параметры принято передавать именно через регистры или стек. Дополнительные опции в запасе – это обычно хорошо, но не в данном случае. Если вызывающая сторона полагает, что параметры должны передаваться через регистры, то сохраняет параметры в регистрах. Но, если вызываемая сторона полагает, что параметры должны передаваться через стек, то и извлекать их она попытается из стека. Возникает несогласованность, и в таком случае весьма вероятно, что из стека будут считаны мусорные значения, что приведёт к логическим ошибкам в коде и аварийному завершению программы.

Как обеспечить, чтобы и вызывающая сторона, и вызываемая передавали параметры в общий источник? Думаю, вы уже догадались: здесь задействуется соглашение о вызовах функций.

Конкретно, такое соглашение о вызовах описывает следующие аспекты:

  • В каком порядке передаются параметры функции: слева направо или справа налево?

  • Каким способом передаются параметры функций и возвращаемые значения: через стек или через регистры?

  • Какие регистры остаются после вызова функции в таком же виде, как и до него?

  •  Какая сторона отвечает за очистку кадра стека: вызывающая или вызываемая?

  • Как будут обрабатываться присутствующие в C функции с переменным количеством аргументов?

В 32-битных программах действовало множество соглашений о вызовах, в частности, cdeclstdcallfastcallthiscall и т.д. В то время программы сильно страдали от проблем с совместимостью. В 64-битных программах по всем этим вопросам в основном удалось добиться унификации. Есть два основных соглашения о вызовах функций, действующих в ABI Windows x64 и x86-64 System V соответственно (хотя, официальных названий у них нет). Необходимо подчеркнуть, что способ передачи параметров в функциях касается только соглашения о вызовах функций, но не уровня оптимизации кода. Вы ведь не хотите, чтобы фрагменты кода, скомпилированные с разными уровнями оптимизации, не поддавались линковке друг с другом, верно?

Конечно, внедрение такой конкретной регламентации бывает утомительным. Если вас интересует эта тема, то можете посмотреть соответствующие разделы рассказывающей об этом документации. Ниже мы обсудим лишь некоторые наиболее интересные темы.

Замечание: всё нижеизложенное применяется лишь в случаях, когда вызовы функций действительно происходят. Если функция полностью встраивается, то параметры функции не передаются. В современном языке C++ оптимизации, связанные со встраиванием функций, обычно происходят в рамках одной единицы компиляции (один файл). Для кода, охватывающего несколько единиц компиляции, необходимо активировать LTO (оптимизацию во время линковки). Код, действующий сразу на уровне нескольких динамических библиотек, пока встраиванию не поддаётся.

Если структуры меньше 16 байт, то их эффективнее передавать по значению, чем по ссылке.

Такой тезис давно в ходу, но мне так и не удалось найти, на чём он основан. Но, не так давно, взявшись исследовать соглашения о вызовах функций, я выяснил причину.  Во-первых, если структура по размеру меньше или равна 8 байт, то её можно поместить непосредственно в 64-битный регистр для передачи параметров. При передаче параметров через регистры требуется обращаться к памяти не так часто, как при передаче по ссылке, соответственно, такой метод более эффективен, и это хорошо. А что насчёт 16 байт? ABI System V позволяет разделить 16-байтную структуру на две 8-байтные части, а затем по отдельности передавать их через регистры. В данном случае передача по значению действительно эффективнее, чем по ссылке. Рассмотрим следующий код:

#include <cstdio>struct X {    size_t x;    size_t y;};extern void f(X);extern void g(const X&);int main() {    f({1, 2}); // передача по значению    g({1, 2}); // передача по ссылке}

Вот как выглядит сгенерированный код:

main:        sub     rsp, 24        mov     edi, 1        mov     esi, 2        call    f(X)        movdqa  xmm0, XMMWORD PTR .LC0[rip]        mov     rdi, rsp        movaps  XMMWORD PTR [rsp], xmm0        call    g(X const&)        xor     eax, eax        add     rsp, 24        ret.LC0:        .quad   1        .quad   2

В ABI System V указано, что первые шесть целочисленных параметров можно передавать, соответственно, в регистрах rdirsirdxrcxr8r9. В ABI Windows x64 указано, что первые четыре целочисленных параметра можно передавать, соответственно, в регистрах rcxrdxr8r9. Если регистры исчерпаны, то далее параметры передаются через стек. К целочисленным параметрам относятся charshortintlonglong long и другие базовые целочисленные типы, плюс типы указателей. Для параметров с плавающей точкой и параметров типов SIMD предусмотрены выделенные регистры, которые в этой статье подробно не рассматриваются.

Как видите, 1 и 2 передаются функции f через регистры edi и esi соответственно, тогда как  в g передаётся адрес временной переменной. Правда, так работает только ABI System V. Что касается ABI Windows x64, если структура крупнее 8 байт, то передавать её можно только по ссылке. Если вышеприведённый код скомпилировать под Windows, то получим следующий результат:

main:        sub     rsp, 56        lea     rcx, QWORD PTR [rsp+32]        mov     QWORD PTR [rsp+32], 1        mov     QWORD PTR [rsp+40], 2        call    void f(X)        lea     rcx, QWORD PTR [rsp+32]        mov     QWORD PTR [rsp+32], 1        mov     QWORD PTR [rsp+40], 2        call    void g(X const &)        xor     eax, eax        add     rsp, 56        ret     0

Как видите, код, сгенерированный для обоих вызовов функций, идентичен. Таким образом, при работе с ABI Windows x64 не имеет значение, как именно передаётся структура крупнее 8 байт, по ссылке или по значению. В обоих случаях генерируется одинаковый код.

unique_ptr и raw_ptr одинаковы по эффективности

Что ж, ранее я в этом совершенно не сомневался — в конце концов, unique_ptr – это простая оболочка, в которую завёрнут сырой указатель. Но однажды мне довелось посмотреть лекцию с конференции CPPCON «There are no zero-cost abstractions», заставившую меня о многом задуматься. Я понял, сколь многое принимал как данность. Здесь мы не будем обсуждать дополнительные издержки, провоцируемые исключениями (деструкторы требуют, чтобы компилятор генерировал дополнительный код для очистки кадра стека). Давайте обсудим, можно ли передавать через регистры объект C++, который меньше 8 байт. С совершенно тривиальным типом это работает без проблем; он ведёт себя практически так же, как структура языка C. А что делать, если тип не тривиальный?

Например, если определяется пользовательский копирующий конструктор, то допустимо ли класть его в регистр? Логика подсказывает, что нет. Почему? Как известно, C++ позволяет брать адреса параметров функции. Если целочисленный параметр передаётся через регистр, то откуда поступает результат взятия адреса? Давайте это экспериментально выясним:

#include <cstdio>extern void f(int&);int g(int x) {    f(x);    return x;}

Вот как выглядит ассемблер, который будет в итоге сгенерирован:

g(int):        sub     rsp, 24        mov     DWORD PTR [rsp+12], edi        lea     rdi, [rsp+12]        call    f(int&)        mov     eax, DWORD PTR [rsp+12]        add     rsp, 24        ret

Как видите, значение в регистре edi (он используется для передачи первого целочисленного параметра) копируется по адресу rsp+12, который находится в стеке, а затем этот адрес передаётся f. Таким образом, если параметр функции передаётся через регистр, а в некоторых ситуациях может понадобиться его адрес, то компилятор скопирует этот параметр в стек. Правда, пользователи не могут наблюдать эти процессы копирования, поскольку их копирующие конструкторы тривиальны. Любая оптимизация, не затрагивающая конечный результат выполнения кода, компилируется с применением правила «as-if», которое разрешает любые преобразования кода, не изменяющих наблюдаемое поведение программы.

Теперь, если для этого объекта предусмотрен определённый пользователем копирующий конструктор, и предполагается, что параметр передаётся через регистр, это может привести к дополнительным вызовам копирующего конструктора. В таком случае пользователь может наблюдать этот побочный эффект. Конечно же, это неразумно, поэтому не разрешено передавать через регистры определённые пользователем копирующие конструкторы. А что насчёт передачи через стек? На самом деле, в таком случае вы столкнётесь со схожими дилеммами, возникающими при копировании. Следовательно, приходим к тому, что такие объекты могут передаваться только по ссылке. Обратите внимание: если явно пометить копирующий конструктор как delete, то тоже будет считаться, что это определённый пользователем копирующий конструктор.

Итак, что касается unique_ptr, его можно передавать только по ссылке. Независимо от того, как вы запишете сигнатуру функции — как void f(unique_ptr<int>) или void f(unique_ptr<int>&), двоичный код, генерируемый в точке передачи параметров будет одинаковым. Однако, сырые указатели можно безопасно передавать через регистры. Резюмируя: эффективность unique_ptr и сырых указателей не вполне одинакова.

В реальной практике вопрос о том, можно ли передавать нетривиальный объект C++ через регистры — ещё сложнее. Конкретные детали описаны в соответствующих разделах документации по интересующим вас ABI, я не буду здесь подробно в них вдаваться. Кроме того, не вполне понятно, к ABI какого уровня относятся правила передачи объектов C++ — ABI операционной системы или ABI компилятора C++.

Стандарт C++

Вот мы и обсудили гарантии, которые предоставляются на уровне операционной системы. Поскольку этот материал тяготеет к низкоуровневому коду и во многом опирается на ассемблер, его сложно воспринимать читателям, которые не слишком хорошо знакомы с ассемблером. Однако то, что изложено в этом разделе, в целом не касается ассемблера, так что вы в нём уверенно разберётесь.

Как известно, в стандарте C++ нет прямой спецификации ABI, но некоторые правила всё-таки действуют. Например, в стандарте предъявляются некоторые требования к реализациям компиляторов, например:

  • Адреса членов структур возрастают в соответствии с порядком их объявления (объяснение); так гарантируется, что компиляторы не будут переупорядочивать члены структур.

  • Структуры, удовлетворяющие ограничению стандартного размещения должны быть совместимы по компоновке с соответствующими структурами языка C.

  • Структуры, удовлетворяющие ограничению тривиальной копируемости можно копировать при помощи memmove или memcpy, получая таким образом идентичный новый объект.

  •   …

Возникает вопрос: в дальнейшем, по мере того, как продолжают выходить новые версии C++, могу ли я рассчитывать, что, скомпилировав один и тот же код с применением нового стандарта и с применением старого — будут ли результаты одинаковыми? Здесь мы не учитываем, как может повлиять на этот процесс использование макросов, управляющих версиями C++ при условной компиляции. Ответ зависит от того, какие гарантии совместимости на уровне ABI предоставляются в стандарте C++. На самом деле, стандарт C++ стремится обеспечить обратную совместимость. Это означает, что, при наличии двух фрагментов кода, один из которых собран в соответствии со старым стандартом и в соответствии с новым, сгенерированный в итоге код должен получаться одинаковым.

Правда, есть совсем немногочисленные исключения (я нашёл только два следующих; если знаете ещё – смело сообщите их в комментариях):

  • В C++17 noexcept ввели в состав типа функции, и это затрагивает имя-украшение (mangling name), генерируемое для функции.

  • В C++20 появилось no_unique_address, до сих пор прямо не поддерживаемое в MSVC, поскольку в противном случае это сломало бы ABI.

Чаще бывает так, что в свежих версиях C++ новые языковые возможности вводятся вместе с новыми ABI, так, чтобы это не затрагивало старый код. Обсудим, например, две новые фичи, добавленные в C++23:

Явное объявление объектного параметра

До C++23 не существовало узаконенного способа получить адрес функции-члена. Всё, что было в наших силах — это получить указатель члена (о том, что он собой представляет, рассказано в этой статье):

struct X {    void f(int);};auto p = &X::f;// p — это указатель на функцию-член X// Тип p — это void (X::*)(int)

Был всего один способ использовать функцию-член для обратного вызова: её требовалось обернуть в лямбда-выражение:

struct X {    void f(int);};using Fn = void(*)(X*, int);Fn p = [](A* self, int x) { self->f(x); };

На самом деле, это довольно неудобно и просто не нужно, так как этот слой-обёртка может только привести к дополнительным издержкам при вызове функций. В какой-то степени эта проблема сложилась исторически: в 32-битных системах соглашение о вызове функций-членов было довольно своеобразным (пресловутый thiscall), а в C++ не было содержимого, которое касалось бы вызовов функций — поэтому и создавался указатель на функцию-член. Для сохранения совместимости на уровне ABI старый код менять нельзя, а новый — можно. В C++23 добавилось явное объявление объектных параметров, поэтому теперь мы можем чётко определять, как именно передаётся this, и даже использовать передачу по значению:

struct X {    // Здесь 'this' — просто маркер, помогающий отличать этот новый синтаксис от старого     void f(this X self, int x); // передача по значению    void g(this X& self, int x); // передача по ссылке};

Функции, помеченные явным this, также могут напрямую получать собственные адреса, как самые обычные функции:

auto f = &X::f; // тип f — это void(*)(X, int)auto g = &X::g; // тип g — это void(*)(X*, int)

Так что такой стиль можно приспособить и к новому коду, от этого одни плюсы и никаких минусов.

Статический Operator()

Некоторые объекты функций, содержащиеся в стандартной библиотеке, не имеют иных членов кроме как operator(). Таков, например std::hash:

template <class T>struct hash {    std::size_t operator()(T const& t) const;};

Пусть это и пустая структура, поскольку operator() — это функция-член, в ней всё-таки есть неявный параметр this. При работе с невстраиваемыми вызовами всё равно требуется передавать бесполезный указатель на null. Эта проблема была решена в C++23, где можно непосредственно определить static operator(), тем самым её избегая:

template <class T>struct hash {    static std::size_t operator()(T const& t);};

static означает, что это статическая функция, и она продолжает использоваться точно так как раньше:

std::hash<int> h;std::size_t n = h(42);

Здесь hash приведено просто в качестве примера. В реальности код стандартной библиотеки не требуется менять для совместимости с ABI. Новый код может пользоваться данной возможностью и избегать ненужной передачи this.

Специфика компиляторов

Теперь переходим к главной теме поста: деталям, определяемым при реализации. Думаю, материал именно из этого раздела критикуется чаще всего. Но оправданно ли это? Давайте разберём эту тему по частям.

Де-факто стандарт

В конечном итоге в C++ не обойтись без реализации некоторых абстракций, и, если в стандарте не указано, как их реализовывать, это остаётся на усмотрение компилятора. Например:

  • Правила украшения имён (для реализации перегрузки функций и шаблонных функций)

  • Компоновка сложных типов (напр., те, в которых предусмотрено виртуальное наследование)

  • Компоновка таблиц виртуальных функций

  • Реализация RTTI

  • Обработка исключений

Если компиляторы реализуют эти части по-разному, то готовые двоичные файлы, получающиеся в результате работы компиляторов, естественно, будут несовместимы и смешиванию не поддаются.

В 1990-е, в золотой век C++ различные вендоры всерьёз занимались разработкой и внедрением собственных компиляторов, при этом расширяя собственную пользовательскую аудиторию, конкурируя за пользователей. В условиях такой конкуренции была обычна ситуация, в которой разные компиляторы использовали разные ABI. Шло время, данный исторический период закончился. Кто-то прекратил выпускать обновления или просто поддерживал уже имеющиеся версии, не придерживаясь новых стандартов C++. Когда волна спала, сохранились лишь три крупных компилятора: GCC, Clang и MSVC.

В настоящее время ABI компиляторов C++ в основном унифицирован, остались всего два основных ABI:

  • Itanium C++ ABI, документация по нему выложена в открытом доступе

  • MSVC C++ ABI, официальной документации по нему нет, но здесь выложена неофициальная версия

Пусть этот интерфейс и называется Itanium C++ ABI, на самом деле это кросс-архитектурный ABI для C++. Его используют почти все компиляторы C++ кроме MSVC, хотя, есть небольшие различия в деталях обработки исключений. Исторически сложилось так, что каждый из компиляторов C++ обращался с C++ ABI по-своему. Когда компания Intel активно продвигала Itanium, стремились избежать проблем с несовместимостью, поэтому создали стандартизированный ABI для всех вендоров C++, ориентировавшихся на Itanium. Позже по разным причинам GCC потребовалось внести изменения в свой внутренний ABI. С учётом того, что они уже поддерживали Itanium ABI (для работы с процессорами Itanium), было решено расширить определение ABI так, чтобы оно охватывало все архитектуры, а не создавать своё собственное. С тех пор все крупные компиляторы кроме MSVC взяли на вооружение кросс-архитектурный Itanium ABI, и так сложилось, что сам процессор Itanium уже не поддерживается, а его ABI — по-прежнему поддерживается.

На платформах Linux как GCC, так и Clang используют Itanium ABI. Поэтому код, собранный двумя этими компиляторами, получается интероперабельным. Его можно перелинковать в один файл и запустить. На платформах Windows ситуация чуть сложнее. Применяемый по умолчанию инструментарий MSVC использует собственный ABI. Однако, дополнительно к инструментарию MSVC, GCC также был портирован на Windows, и в итоге получился инструментарий MinGW. В нём по-прежнему используется Itanium ABI. Два этих ABI несовместимы, и фрагменты кода, скомпилированные каждым из них, нельзя непосредственно перелинковать друг с другом. Компилятор Clang под Windows позволяет на уровне опций компиляции контролировать, какие из этих ABI будут использоваться.

Обратите внимание: поскольку MinGW работает под Windows, естественно, что генерируемые им соглашения о вызове кода, естественно, стремятся соответствовать Windows x64 ABI, и итоговый формат исполняемого файла также получается PE32+. Правда, в нём всё равно используется Itanium ABI. Связи между ними двумя может и не быть.

Учитывая, какая огромная база кода написана на C++, эти два ABI C++ в основном стабилизировались и дальше меняться не будут. Следовательно, сейчас мы вправе сказать, что у компиляторов C++ есть стабильные ABI. Как вам это, не отличается ли от той точки зрения, которая в онлайне считается мейнстримовой? Но факты именно таковы.

В MSVC стабильность ABI гарантируется в версии 2015 и выше. В GCC интерфейс Itanium ABI используется, начиная с версии 3.4, и именно с тех пор гарантируется стабильность ABI.

Обходной маневр

Хотя базовый ABI больше и не меняется, при обновлении версий компилятора по-прежнему возможны нарушения работы ABI в скомпилированных библиотеках. Почему так?

Понять это несложно. Во-первых, компиляторы — тоже софт, а в любом софте могут быть багги. Иногда, чтобы исправить баг, разработчики вынуждены вносить коренные изменения в ABI (обычно они подробно объясняются в заметках по релизу каждой новой версии). Например, в компиляторе GCC предусмотрена опция -fabi-version именно для управления этой разницей в версиях. Вот некоторые выдержки из её содержимого:

  • Версия 7 впервые появилась в G++ 4.8, здесь nullptr_t трактуется как встроенный тип, а также исправлено кодирование имён лямбда-выражений в задаваемой по умолчанию области видимости аргумента.

  • Версия 8 впервые появилась в G++ 4.9, в ней исправлено поведение, действующее при подстановке типов функций с использованием квалификаторов функций CV (const volatile).

  • Версия 9 впервые появилась в G++ 5.2, в ней исправлено выравнивание nullptr_t.

Кроме того, сами пользователи могут писать специальный код, позволяющий обходить баги компилятора — это обычно и называется «обходным маневром» (workaround). После того, как баг исправлен, эти обходные маневры могут давать негативный эффект и приводить к несовместимости ABI.

Важные опции

Кроме того, в компиляторах предоставляется ряд опций, предназначенных для управления поведением, и эти опции могут затрагивать ABI, например:

  • -fno-strict-aliasing: отключение строгого совмещения имён

  • -fno-exceptions: Отключение исключений

  • -fno-rtti: Отключение RTTI

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

Недавно довелось столкнуться с такой ситуацией. Я писал при помощи pybind11 обёртки на Python для некоторых функций LLVM. Для работы Pybind11 требуется, чтобы был активирован RTTI, но в сборке LLVM, создаваемой по умолчанию, отключены как исключения, так и RTTI, поэтому перелинковать код никак не удавалось. Сначала я скомпилировал версию LLVM с включённым RTTI, что привело к раздуванию двоичных файлов. Позже осознал, что необходимости в этом нет, так как я не использовал информацию RTTI для работы с типами LLVM. Компилятор просто считал, что я её использую, так как и те, и другие данные были записаны в одном и том же файле. Так что для решения этой проблемы я вынес в отдельную динамическую библиотеку ту часть кода, работа которой зависела от LLVM, а затем связал её с той частью кода, которая зависела от pybind11.

Среда выполнения и библиотека

В этом разделе речь пойдёт в основном о стабильности ABI на уровне тех библиотек, от которых зависит программа, написанная на C++. Что касается исполняемой программы, в идеале при замене старой версии динамической библиотеки на новую это никак не должно сказываться на работе программы.

У каждого из трёх главных компиляторов C++ есть собственная стандартная библиотека:

Как я упоминал выше, стандарт C++ стремится обеспечить обратную совместимость на уровне ABI. Даже при крупных обновлениях, как между C++98 и C++11, такие переходы не сильно повлияли на старый код ABI, а изменений в формулировках ABI Break Change вообще не задокументировано.

Правда, со стандартной библиотекой C++ складывается несколько иная ситуация. При переходе от C++98 к C++11 стандартная библиотека претерпела серьёзные изменения ABI. В стандартной библиотеке изменились требования к реализации некоторых контейнеров, например, std::string. В результате сложилось так, что широко используемая реализация COW больше не соответствует новому стандарту, и в C++11 пришлось брать на вооружение новую реализацию. Из-за этого возник разлом ABI между стандартными библиотеками C++98 и C++11. Но с тех пор ABI стандартной библиотеки в основном остаётся стабильным, и это стараются обеспечить в каждой реализации. Подробно об этом можно почитать в справочных источниках по stllibstdc++ и libc++.

Кроме того, поскольку RTTI и исключения, в принципе, можно отключать, две эти возможности можно обрабатывать при помощи отдельных библиотек среды выполнения; в MSVC для этого предусмотрена vcruntime, а в libc++ — libcxxabi.

Также стоит отметить, что в libcxxabi также поддерживается инициализация статических локальных переменных, в основном с применением функций  __cxa_guard_acquire и __cxa_guard_release. Они призваны обеспечить, что статические локальные переменные будут инициализироваться во время выполнения лишь однократно. Если вас интересует какая-то конкретная реализация, то лучше посмотреть соответствующий исходный код.

В среде выполнения также есть библиотеки, отвечающие за некоторые низкоуровневые функции, например, libgcc и compiler-rt.

Программы C++ требуется связывать не только со стандартной библиотекой, но и со средой выполнения C:

  • Под Windows обязательно нужно перелинковать код с CRT.

  • Под Linux эти факторы зависят от дистрибутива и от используемого окружения компилятора, там код необходимо линковать с glibc или musl.

В среде выполнения C требуется не только предоставить реализацию стандартной библиотеки, но и позаботиться об инициализации программы и об очистке после работы. Среда выполнения отвечает за вызов функции main, а также за управление процессом запуска и завершения программы. Поэтому для большинства программ, работающих в операционной системе, такая линковка принципиально важна.

В идеале, конечно же, нужно при обновлении компилятора обновлять и соответствующие версии среды выполнения, чтобы не было ненужных проблем. Но в реальных проектах могут быть очень сложные зависимости, потенциально способные спровоцировать цепную реакцию.

Пользовательский код

Наконец, давайте обсудим, какие проблемы с ABI могут возникать из-за изменений в пользовательском коде как таковом. Если вы хотите распространять вашу библиотеку в двоичном формате, то совместимость с ABI становится принципиально важна, особенно после того, как пользовательская аудитория достигает определённого размера.

В первом подразделе, где мы обсуждали соглашения о вызовах, шла речь о случаях несовместимости ABI, обусловленных изменениями в определениях структур. Итак, что делать, ели мы хотим не только обеспечить совместимость с ABI, но и оставить пространство для дальнейшего расширения? В таком случае нужно сделать в среде выполнения следующее:

struct X{    size_t x;    size_t y;    void* reserved;};

При помощи указателя на void* мы резервируем пространство для будущих расширений. На этой основе можно различать разные версии, например:

void f(X* x) {    Reserved* r = static_cast<Reserved*>(x->reserved);    if (r->version == ...) {        // что-то делаем    } else if (r->version == ...) {        // делаем что-то ещё    }

Таким образом можно добавлять новые возможности, не затрагивая при этом уже существующий код.

При предоставлении интерфейсов следует уделять особое внимание типам, у которых в параметрах функций указаны пользовательские деструкторы. Допустим, мы хотим предоставить std::vector как возвращаемое значение. Например, давайте скомпилируем приведённый ниже простой код в динамическую библиотеку и воспользуемся при этом опцией \MT — она позволит статически связать Windows CRT.

__declspec(dllexport) std::vector<int> f() {    return {1, 2, 3};}

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

#include <vector>std::vector<int> f();int main() {    auto vec = f();}

Скомпилируем, запустим — и она сразу же аварийно завершится. Если мы перекомпилируем динамическую библиотеку, предварительно отключив опцию \MT, а затем запустим, то всё будет работать нормально. Странно: почему же зависимая динамическая библиотека, статически связывающая CRT, приводит к отказу кода?

Размышляя о вышеприведённом коде, не сложно понять, что vec конструируется внутри динамической библиотеки, а деструкция — уже в функции main. Точнее говоря, память выделяется внутри динамической библиотеки, а высвобождается в функции main. Но в каждой CRT есть собственные malloc и free (подобно межпроцессной памяти). Невозможно при помощи CRT B высвободить память, которая была выделена CRT A. В этом суть проблемы. Таким образом, без применения статической линковки с CRT всё нормально; все функции будут пользоваться одними и теми же malloc и free. Это касается не только Windows CRT, но и glibc или musl под Linux. Вот здесь приведены примеры с кодом; можете смело опробовать их самостоятельно.

extern “C”

Вышеописанная ситуация может произойти с любым типом C++, для которого определён пользовательский деструктор. По разным причинам в ситуациях, когда вызовы конструктора и деструктора пересекают границы динамической библиотеки, нарушается контракт RAII, и это приводит к серьёзным ошибкам.

Как решить эту проблему? Естественно, не использовать в параметрах и возвращаемых значениях функций типы с деструкторами, допустимы только POD-типы.

Так, вышеприведённый пример следует видоизменить вот так:

using Vec = void*;__declspec(dllexport) Vec create_Vec() {    return new std::vector<int>;}__declspec(dllexport) void destroy_Vec(Vec vec) {    delete static_cast<std::vector<int>*>(vec);}

После чего использоваться он будет так:

using Vec = void*;Vec create_Vec();void destroy_Vec(Vec vec);int main() {    Vec vec = create_Vec();    destroy_Vec(vec);}

На самом деле, здесь мы инкапсулируем его в стиле RAII, принятом в C. Более того, если вы хотите решить проблему с линковкой, возникающую между C и C++ из-за разных правил украшения имён, то можете декорировать функцию с применением extern "C":

extern "C" {    Vec create_Vec();    void destroy_Vec(Vec vec);}

Таким образом, язык C также может использовать вышеупомянутые экспортированные функции.

Но, если база кода велика, то инкапсулировать все функции в такой API определённо нереалистично. В таком случае типы C++ необходимо предоставлять в экспортированных интерфейсах, при этом тщательно управляя зависимостями (например, статически линковать все зависимые библиотеки). Выбор делается в каждом случае отдельно и зависит от размера и сложности проекта.

Заключение

Вот мы, наконец, и обсудили все основные факторы, влияющие на ABI программ, написанных на C++. Естественно, стабильность ABI стремятся обеспечить сразу на нескольких уровнях и в стандарте C++, и в библиотеках среды выполнения, и даже вендоры компиляторов этим занимаются. ABI C++ не так плох или нестабилен, как жалуются некоторые. При разработке небольших проектов достаточно статически линковать исходный код, чтобы исключить практически все проблемы с совместимостью. Если проект большой и долгоиграющий, то он обрастает сложными зависимостями, поэтому при обновлениях определённых версий библиотек можно случайно обрушить программу. Но это не вина C++. Управление большими проектами в принципе выходит за рамки строго языковой; не рассчитывайте, что сможете решить такие проблемы, просто поменяв один язык на другой. На самом деле, осваивая программную инженерию, мы учимся справляться с неизмеримо сложными задачами и обеспечивать стабильность сложных систем.

Вот ещё несколько ссылок:

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