Een grondig begrip van de C++ ABI

Deze situatie is niet ongebruikelijk in de informatica. Veel concepten missen strikte definities en zijn eerder conventionele understandings. In plaats van ons te verliezen in definities, kijken we naar wat deze binaire interfaces daadwerkelijk zijn en welke factoren hun stabiliteit beïnvloeden.

CPU & OS

Een definitief uitvoerbaar bestand draait uiteindelijk op een specifiek besturingssysteem op een specifieke CPU. Als de CPU-instructiesets verschillen, leidt dit onvermijdelijk tot binaire incompatibiliteit; programma's voor ARM kunnen bijvoorbeeld niet direct op x64-processoren draaien (tenzij er virtualisatietechnologie wordt gebruikt).

Maar wat als de instructiesets compatibel zijn? x64-processoren zijn bijvoorbeeld compatibel met de x86-instructieset. Betekent dit dat een x86-programma gegarandeerd kan draaien op een x64-besturingssysteem? Hier komt het besturingssysteem in beeld. Specifiek moeten factoren zoals het Objectbestandformaat, de Datarepresentatie, de Function Calling Convention en de Runtime Library worden meegewogen. Deze punten kunnen worden beschouwd als ABI-regels op besturingssysteemniveau.

In het geval van het x64-platform (waarbij x64, x86-64, x86_64, AMD64 en Intel 64 allemaal verwijzen naar de 64-bit versie van de x86-instructieset) zijn er twee veelvoorkomende ABI's:

  • Windows x64 ABI voor 64-bit Windows-besturingssystemen.
  • x86-64 System V ABI voor 64-bit Linux en diverse UNIX-achtige besturingssystemen.

Het aanroepen van een functie uit een dynamische bibliotheek kan worden gezien als drie stappen:

  1. Het analyseren (parsen) van de dynamische bibliotheek volgens een bepaald formaat.
  2. Het opzoeken van het functieadres in het resultaat op basis van de symboolnaam.
  3. Het doorgeven van functieparameters en het aanroepen van de functie.

Objectbestandformaat

Om een dynamische bibliotheek te kunnen analyseren, zijn de ABI-regels voor het objectbestandformaat essentieel. Windows x64 gebruikt het PE32+ formaat (de 64-bit versie van Portable Executable 32-bit). De System V ABI gebruikt het ELF (Executable Linkable Format) formaat. Door gebruik te maken van parsing-libraries zoals pe-parse en elfio kan men de mapping tussen functienamen en functieadressen verkrijgen uit de symbooltabellen.

Datarepresentatie

Zodra het functieadres bekend is, moeten parameters worden doorgegeven. Hierbij is consistentie in datarepresentatie cruciaal.

Stel dat een bestand wordt gecompileerd tot een dynamische bibliotheek met de volgende structuur:

struct X {
    int a;
    int b;
};

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

Bij een latere upgrade wordt de structuur in de code van de gebruiker gewijzigd naar:

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

Als de gebruiker vervolgens probeert te linken naar de oude dynamische bibliotheek:

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

Dan zal dit falen. Dit type fout is een zogenaamde ODR (One Definition Rule) schending.

Naast actieve code-wijzigingen garandeert de ABI in de datarepresentatie ook de stabiliteit van de layout, zoals de grootte en alignment van basistypen. Zo specificeert Windows x64 long als 32 bits, terwijl System V long specificeert als 64 bits. Ook de grootte en alignment van struct en union zijn vastgelegd.

Let op dat de C-taalstandaard zelf geen ABI specificeert. De System V ABI is primair geschreven in C-terminologie en kan worden beschouwd als een ABI voor C. Bij Windows x64 is de grens tussen C en C++ minder scherp.

Function Calling Convention

Een functie is in essentie een stuk binaire data; het aanroepen ervan betekent springen naar het beginadres, de code uitvoeren en terugspringen. Parameters moeten ergens worden opgeslagen zodat zowel de aanroeper (caller) als de aangeroepene (callee) erbij kunnen. De opties zijn:

  • Global (globale variabelen)
  • Heap
  • Register
  • Stack

Hoewel globale variabelen of de heap gebruikt kúnnen worden (bijvoorbeeld C++20 stackless coroutines), zijn registers en de stack de standaard voor gewone functies. Inconsistentie hierin — waarbij de caller parameters in registers plaatst maar de callee ze van de stack verwacht — leidt tot het lezen van garbage-waarden, logische fouten en crashes.

De Function Calling Convention legt dit vast:

  • De volgorde van parameteroverdracht (links-naar-rechts of rechts-naar-links).
  • De methode voor parameters en retourwaarden (stack of registers).
  • Welke registers ongewijzigd moeten blijven na een aanroep.
  • Wie verantwoordelijk is voor het opruimen van de stack frame (caller of callee).
  • Hoe variabele functies in C worden afgehandeld.

In 32-bit programma's waren er veel conventies (cdecl, stdcall, etc.), wat leidde tot veel compatibiliteitsproblemen. In 64-bit is dit grotendeels geünificeerd naar de twee hoofd-ABI's (Windows x64 en System V). Dit staat los van het optimalisatieniveau van de compiler.

Opmerking: De volgende discussies gelden alleen als functies daadwerkelijk worden aangeroepen. Bij volledige inlining vindt parameteroverdracht niet plaats. LTO (Link Time Optimization) is nodig voor inlining over verschillende compilatie-units; over dynamische bibliotheken heen is dit momenteel niet mogelijk.

Efficiëntie van kleine structs (< 16 bytes)

Er wordt vaak beweerd dat het doorgeven van structs kleiner dan 16 bytes via waarde efficiënter is dan via referentie. De reden hiervoor ligt in de registers. Als een struct $\le$ 8 bytes is, past deze direct in een 64-bit register. In de System V ABI kan een struct van 16 bytes worden gesplitst in twee delen van 8 bytes en zo via registers worden doorgegeven, wat minder geheugentoegang vereist dan een referentie.

Beschouw deze code:

#include <cstdio>
struct X {
    size_t x;
    size_t y;
};
extern void f(X);
extern void g(const X&);
int main() {
    f({1, 2}); // pass by value
    g({1, 2}); // pass by reference
}

In de System V ABI worden de waarden 1 en 2 voor functie f via registers edi en esi doorgegeven, terwijl voor g het adres van een tijdelijke variabele wordt gebruikt.

In de Windows x64 ABI is dit anders: als een struct groter is dan 8 bytes, wordt deze altijd via referentie doorgegeven. De gegenereerde code voor zowel f als g is daar identiek.

uniqueptr versus rawptr

Men gaat er vaak vanuit dat unique_ptr dezelfde efficiëntie heeft als een raw pointer omdat het slechts een wrapper is. Echter, objecten die niet "trivial" zijn (bijvoorbeeld objecten met een door de gebruiker gedefinieerde copy constructor), mogen vaak niet via registers worden doorgegeven.

Als een parameter via een register wordt doorgegeven maar de code het adres van deze parameter nodig heeft, kopieert de compiler de waarde naar de stack. Bij triviale types is dit onzichtbaar (as-if rule). Echter, bij een user-defined copy constructor zou dit leiden tot extra aanroepen die de gebruiker kan observeren, wat onaanvaardbaar is. Daarom worden dergelijke objecten alleen via referentie doorgegeven.

Omdat unique_ptr in bepaalde scenario's niet via registers kan worden doorgegeven terwijl raw pointers dat wel kunnen, is de efficiëntie niet exact gelijk.

C++ Standard

De C++ standaard specificeert geen ABI, maar stelt wel eisen aan compiler-implementaties:

  • Adressen van struct-leden nemen toe volgens hun declaratievolgorde (geen reordering door de compiler).
  • Structs die voldoen aan Standard Layout moeten layout-compatibel zijn met overeenkomstige C-structs.
  • Structs die Trivially Copyable zijn, kunnen via memmove of memcpy worden gekopieerd.

De standaard streeft naar backward compatibility, maar er zijn uitzonderingen:

  • C++17 maakte noexcept onderdeel van het functietype, wat de mangling-naam beïnvloedt.
  • C++20 introduceerde nouniqueaddress, wat MSVC nog niet direct ondersteunt vanwege mogelijke ABI-breuken.

Expliciete Objectparameters (C++23)

Voor C++23 was er geen legale manier om het adres van een memberfunctie te verkrijgen; men kon alleen een member pointer krijgen. Om een memberfunctie als callback te gebruiken, moest men deze wrappen in een lambda, wat overhead veroorzaakte.

C++23 introduceerde expliciete objectparameters:

struct X {
    void f(this X self, int x); // pass by value
    void g(this X& self, int x); // pass by reference
};

Functies met een expliciete this kunnen nu direct hun functieadres verkrijgen, net als gewone functies:

auto f = &X::f; // type is void(*)(X, int)
auto g = &X::g; // type is void(*)(X*, int)

Statische Operator() (C++23)

Sommige function objects in de standaardbibliotheek (zoals std::hash) hebben geen members behalve operator(). Omdat dit een memberfunctie is, wordt er impliciet een this-parameter doorgegeven. Zelfs als de struct leeg is en de call niet wordt geïnlined, moet er een nutteloze null-pointer worden doorgegeven. C++23 lost dit op door statische operator() toe te staan:

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

Compiler Specific

De Facto Standaard

Sommige abstracties moeten worden geïmplementeerd zonder dat de standaard voorschrijft hoe. Dit is overgelaten aan de compiler:

  • Name mangling regels (voor overloading en templates).
  • Layout van complexe types (bijv. virtuele inheritantie).
  • Virtual function table layout.
  • RTTI-implementatie en Exception handling.

Vroeger hadden veel vendors hun eigen ABI's, maar tegenwoordig zijn er drie grote spelers over: GCC, Clang en MSVC. Er zijn nu hoofdzakelijk twee C++ ABI's:

  1. Itanium C++ ABI: Een cross-architecture ABI die door bijna alle compilers wordt gebruikt, behalve MSVC.
  2. MSVC C++ ABI: De specifieke ABI van Microsoft.

Op Linux gebruiken zowel GCC als Clang de Itanium ABI, waardoor hun code interoperabel is. Op Windows is het complexer: de standaard MSVC-toolchain gebruikt zijn eigen ABI, terwijl MinGW (GCC op Windows) de Itanium ABI gebruikt. Deze twee zijn incompatibel. Clang op Windows kan via opties kiezen welke van de twee hij gebruikt.

Workarounds

Zelfs als de basis-ABI niet verandert, kunnen compiler-updates leiden tot breuken bij gecompileerde bibliotheken. Dit komt vaak door het oplossen van bugs. GCC heeft bijvoorbeeld de optie -fabi-version om tussen verschillende versies te schakelen (bijv. correcties in de alignment van nullptr_t). Daarnaast kunnen "workarounds" die gebruikers schreven voor oude compiler-bugs problematisch worden zodra de bug is opgelost.

Belangrijke Opties

Compiler-opties kunnen de ABI beïnvloeden:

  • -fno-strict-aliasing: Schakelt strict aliasing uit.
  • -fno-exceptions: Schakelt exceptions uit.
  • -fno-rtti: Schakelt RTTI uit.

Wanneer bibliotheken met verschillende opties worden gelinkt, kunnen compatibiliteitsproblemen ontstaan (bijv. pointer propagation errors bij strict aliasing). Een voorbeeld is het linken van pybind11 (vereist RTTI) aan LLVM (schakelt RTTI standaard uit), wat opgelost kan worden door de afhankelijkheden in aparte dynamische bibliotheken te splitsen.

Runtime & Library

Verschillende compilers hebben hun eigen standaardbibliotheken:

  • MSVC → msvc stl
  • GCC → libstdc++
  • Clang → libc++

Hoewel de C++ standaard backward compatibility nastreeft, was er tussen C++98 en C++11 een grote ABI-breuk in de standaardbibliotheek. Dit kwam door gewijzigde eisen voor container-implementaties (zoals std::string), waardoor de veelgebruikte COW (Copy-On-Write) implementatie niet meer voldeed.

Daarnaast zijn er runtime libraries verantwoordelijk voor low-level functies (libgcc, compiler-rt) en de C runtime:

  • Windows → CRT.
  • Linux → glibc of musl.

De C runtime is essentieel voor programmainitialisatie, cleanup en het aanroepen van de main-functie.

User Code

Voor wie bibliotheken in binaire vorm distribueert, is ABI-compatibiliteit cruciaal. Om ruimte te laten voor toekomstige uitbreidingen zonder de ABI te breken, kan men runtime-afhandeling gebruiken met gereserveerde ruimte:

struct X {
    size_t x;
    size_t y;
    void* reserved; // Ruimte voor toekomstige extensies
};

Bij het exposeren van interfaces moet men voorzichtig zijn met types die custom destructoren hebben. Een voorbeeld is het retourneren van een std::vector uit een dynamische bibliotheek op Windows met de /MT optie (statisch linken aan CRT).

Als de vector in de bibliotheek wordt aangemaakt maar in de hoofdapplicatie wordt vernietigd, crasht het programma. Dit komt omdat elke CRT zijn eigen malloc en free heeft; geheugen gealloceerd door CRT A kan niet worden vrijgegeven door CRT B. Door statisch linken uit te schakelen (zodat iedereen dezelfde CRT gebruikt), wordt dit opgelost.

extern “C”

Om problemen met destructoren over bibliothekgrenzen heen te voorkomen, is het raadzaam om in interfaces alleen POD-types (Plain Old Data) te gebruiken en geen types met destructoren.

In plaats van een std::vector direct te retourneren, kan men een C-stijl RAII wrapper gebruiken:

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

Met extern "C" wordt bovendien de name-mangling van C++ omzeild, waardoor de functies ook vanuit de C-taal bruikbaar zijn.

Conclusie

De factoren die de ABI van C++ beïnvloeden zijn divers: de standaard, compiler-vendors en runtime libraries. Hoewel er vaak wordt beweerd dat de C++ ABI instabiel is, streven alle betrokkenen naar stabiliteit. Voor kleine projecten elimineert statisch linken met broncode bijna alle problemen. Voor grote projecten met complexe afhankelijkheden kunnen updates leiden tot crashes, maar dit is een kwestie van software engineering en complexiteitsbeheer, niet een fundamenteel defect van de taal zelf.