36.1 Névtorzítás, layout és C++ runtime ABI

Egy natív program linkelési felülete nem ismeri a C++ nyelvet: a szerelő (assembler) és a linker szintjén csak szimbólumok, szakaszok és relokációk léteznek. Amikor két, különböző fordítóval vagy különböző könyvtárverzióval épített objektum találkozik, a találkozás helyét az ABI (Application Binary Interface) szabályai rögzítik. A C nyelvben ezt viszonylag kevés dolog terheli: a hívási konvenció, a struct-layout és a szimbólumnév. A C++ ezt a szerződést kibővíti — névtorzítással, osztályelrendezéssel, virtuális dispatch-csel, RTTI-vel és kivételkezeléssel —, de a bővítés nem a nyelvi szabvány része, hanem egy konkrét ABI implementációé. Ezért ebben az alfejezetben minden állítást konkrét toolchainprofilhoz kötünk: Linux x86-64 System V, GCC 13 vagy Clang 17, -std=c++20, Itanium C++ ABI. Ahol a részlet implementációfüggő vagy verziófüggő, jelöljük.

A korábbi részekből építkezünk: a többszörös öröklés és a virtuális bázis eseteit a Többszörös öröklés és virtuális bázis (24.5) tárgyalta, az ODR és az inline több fordítási egységen átnyúló hatását az ODR, inline és több fordítási egység (33.3), a hívási konvenciót és a stack frame szerkezetét pedig a Hívási konvenció, stack frame és visszatérés (14.2). Ezek ismeretében most a C++ ABI három pillérét — névtorzítás, layout, runtime-támogatás — vesszük sorra, és megmutatjuk, mit lehet ezekből a linkelési felületen megfigyelni.

36.1.1 Névtorzítás: nyelvi név és szimbólumnév

A névtorzítás (name mangling) az a fordítási lépés, amely egy C++ entitás — függvény, változó, típus, sablon-instantiation — deklarációját egyedi, karakterlánc-alapú szimbólumnévvé kódolja. A C++ nyelv a túlterhelést, a névtereket, a tagfüggvényeket és a sablonokat egyetlen névtérben kell hogy kifejezze a linker számára; a linker viszont csak char*-összehasonlítást ismer. A torzítás tehát kényszerű: a nyelvi információk egy részét (paramétertípusok, hozzáférés, osztálykörnyezet) a szimbólumnév hordozza.

Az Itanium C++ ABI mangling-sémája elterjedt Linuxon, macOS-en (Clang), ARM-en és sok más platformon. Fontos, hogy az „Itanium” itt történelmi elnevezés, nem az Intel Itanium ISA-ra utal: a séma egy The SCO Group és mások által kezdeményezett, ISA-független ABI-specifikáció neve, amelyet x86-64-en, AArch64-en, RISC-V-n egyaránt használnak. A Microsoft Visual C++ más sémát használ, ezért MSVC és GCC objektumai nem linkelhetők össze C++-szinten.

Az alapséma: _Z, majd a név hossza és a név, majd a paramétertípusok kódolása. Néhány példa, amelyeket c++filt-tel vissza lehet fejteni:

Forrás Torzított szimbólum
int foo(int) _Z3fooi
double foo(double) _Z3food
void ns::foo() _ZN2ns3fooEv
void Foo::bar() const _ZNK3Foo3barEv
Foo::Foo() (teljes objektum) _ZN3FooC1Ev
Foo::~Foo() (teljes objektum) _ZN3FooD1Ev
Foo típus vtable-je _ZTV3Foo
Foo típus RTTI-ja _ZTI3Foo
Foo típus neve _ZTS3Foo

A K a const tagfüggvényt jelöli, a C1/C2 a konstruktort (teljes vs. bázis-változat), a D0/D1/D2 a destruktort (deleting, complete, base). A V, I, S prefixek rendre vtable, typeinfo, typeinfo-name string. A c++filt visszafejti ezeket, a nm -C a binárison belüli szimbólumokat már demangle-özve listázza.

Fontos hangsúlyozni: a mangling nem része az ISO C++ szabványnak. Két standard-konform fordító ugyanarra a függvényre más szimbólumot generálhat, és ez önmagában megakadályozza az összelinkelést. Az inline és sablon-instantiation esetén az ODR még szigorúbb: ha két TU-ban ugyanarra a szimbólumra eltérő definíció kerül, a viselkedés nem definiált, és a linker gyakran észrevétlenül választ egy példányt.

36.1.2 Osztályelrendezés, vptr, vtable és RTTI

A nem polimorf osztály elrendezése nagyjából követi a C struct szabályait: a nem statikus adattagok deklarációs sorrendben, természetes igazítással, a köztes réseket kitöltve (padding) helyezkednek el. Az sizeof, offsetof és az igazítás ebben az esetben a szokásos C-beli számítással követhető, feltéve, hogy ismerjük a cél-ABI-t.

A polimorf osztály (amelynek van legalább egy virtuális függvénye vagy virtuális bázisa) elrendezése gyökeresen más: az Itanium ABI egy rejtett vptr-t illeszt be, amely a virtuális táblára (vtable) mutat. Ez az ABI adta reprezentáció — a nyelv csak annyit mond, hogy a virtuális hívás a dinamikus típus függvényét hívja, arról nem rendelkezik, hogy ez vtable-lekérdezéssel történik. A vtable tartalma az Itanium ABI szerint:

Index Tartalom
−2 offset-to-top (a legfelső objektumhoz mért eltolás)
−1 _ZTI<Osztály> pointer, az RTTI leírás
0…n virtuális függvények címei deklarációs sorrendben, öröklési szabályok szerint

A vptr a 0. indexre mutat; a negatív indexek elérését a fordulónak kell biztosítania (a dynamic_cast és typeid erre támaszkodik). A vtable nem garantáltan olvasható adat a programból — a hozzáférés az ABI belső szerződése.

Az RTTI (Run-Time Type Information) ezen a felületen egy std::type_info-szerű objektum, amelynek szimbólumneve _ZTI<Osztály>, és amely egy _ZTS<Osztály> névkarakterláncra mutat. A typeid és dynamic_cast ezt használja. Két dinamikusan linkelt modul között a típusazonosság-ellenőrzés attól függ, hogy a typeinfo pointereket egyesíti-e a dynamic linker; ezért DSO-határon a typeid-alapú azonosítás törékeny lehet, és verziófüggő. Ezt a részletet a gyakorlatban ne tekintsük stabil szerződésnek.

36.1.3 Thunkok, pointer-igazítás és tag-függvény pointer

Többszörös öröklésnél a bázis-részobjektum nem feltétlenül a legális this pointerrel esik egybe. Tekintsük:

struct BaseA { virtual ~BaseA() = default; virtual void render() const; };
struct BaseB { virtual ~BaseB() = default; virtual void render() const; };
struct Widget : BaseA, BaseB {
    void render() const override;
};

A Widget-en belül a BaseA részobjektum a 0. offseten, a BaseB részobjektum később helyezkedik el. Amikor egy BaseB*-en keresztül hívjuk a render-t, a hívott függvénynek a Widget-re mutató pointerre van szüksége, nem a BaseB részobjektumra. Ezt egy thunk oldja meg: egy rövid, fordító által generált stub, amely a this pointert a megfelelő eltolással korrigálja, majd a valódi implementációra ugrik. Az Itanium ABI non-virtual thunk-nak nevezi a nem virtuális bázisra vonatkozó korrekciót, és virtual thunk-nak a virtuális bázisért végzett korrekciót.

A thunk nem jelenik meg a forrásban, de a szimbólumtáblában igen: nm csillagozott bejegyzésként vagy non-virtual thunk to Widget::render() alakban mutatja. A BaseB részobjektum saját vtable-je a thunkra mutat, nem közvetlenül a Widget::render-re.

A tag-függvény pointer (int (Widget::*)()) reprezentációja szintén ABI-kérdés. Az Itanium ABI-ban egy nem virtuális tag-függvény pointer egy {ptr, adj} pár, ahol adj az esetleges this-korrekció; virtuális tagfüggvénynél a ptr egy vtable-indexre mutató segédfüggvény (az úgynevezett „virtual member function pointer”), és az adj az öröklési eltolás. Ennek pontos mérete és szemantikája implementációfüggő, és nem hordozható két különböző ABI között — ezért shared library határon tag-függvény pointer átadása nem javasolt.

36.1.4 Kivételkezelés mint ABI-szolgáltatás

A throw és a stack unwinding (veremtakarítás) együttműködést igényel a fordító, a runtime és a dinamikus linker között. A throw egy runtime-függvényt hív (__cxa_throw az Itanium ABI-ban), amely elindítja a kétszakaszos unwindingot. A fordító minden olyan függvényhez, amely kivételt dobhat vagy kezel, két extra szakaszt generál:

A tényleges unwindingot a libgcc_s (vagy libunwind) biztosítja: _Unwind_RaiseException, _Unwind_Resume, _Unwind_DeleteException. A personality routine — GCC-nél __gxx_personality_v0 — dönti el lépésenként, hogy az adott frame hogyan viselkedjen. Ezek a szimbólumok a libstdc++ és libgcc_s közös felületén élnek.

Következmény a gyakorlatra: egy extern "C" felületen átjutó kivétel nem definiált viselkedés, mert a C-fordító által generált frame-ekhez nincs LSDA, így az unwinder nem tudja őket helyesen átlépni. (A gyakorlatban egyes implementációk „átengedik”, de garantált viselkedés nincs.) Ezért az FFI-határon a kivételt a C++ oldalon el kell kapni, és hibakód, std::error_code vagy expected formájában átadni. Ezt a mintát részletesen a Stabil shared library határ és verziózás (36.3) tárgyalja.

Kiegészítő runtime-szimbólumok, amelyeket érdemes ismerni: __cxa_atexit (globális destruktor regisztrálása), __cxa_guard_acquire/__cxa_guard_release (függvény-lokális statikus inicializáció, C++11 óta szálbiztos), __cxa_pure_virtual (tiszta virtuális hívás esetén hívott függvény), __cxa_allocate_exception/__cxa_free_exception. Ha egy binárison nm -D | grep __cxa futtatunk, ezek jól látszanak — de a konkrét készlet a fordító és a standard library verziójától függ.

36.1.5 Standard layout és triviálisság: mit garantál és mit nem

A std::is_standard_layout<T> és std::is_trivial<T> nyelvi tulajdonságok. A standard layout azt jelenti, hogy az osztály megfelel a C-kompatibilis elrendezés bizonyos nyelvi kritériumainak (pl. legfeljebb egy nem statikus adattaggal rendelkező bázis, minden nem statikus tag azonos hozzáférés-szinttel, nincs virtuális tag). A triviálisság azt jelenti, hogy a default constructor, a másoló/mozgató konstruktor, a másoló/mozgató értékadás és a destruktor mind „triviális” (a fordító által generált, szemantikailag üres). Egy triviális, standard layout típus nagyon közel áll egy C structhoz, és gyakran ugyanazzal a calling convention-nel adható át.

Azonban ezek a tulajdonságok önmagukban nem jelentenek ABI-stabilitást. Három okból:

  1. A padding és alignment a cél-ABI-tól függ. Két azonos mezőkkel rendelkező standard layout struct két architektúrán eltérő sizeof-szal rendelkezhet.
  2. A calling convention struct visszaadásnál vagy paraméter-átadásnál külön szabályokat használhat (System V: kis struct register-ben, nagy memóriában). Ezek a szabályok nem a nyelvi típusból, hanem az ABI-ból következnek.
  3. Az empty base optimization (EBO) és a [[no_unique_address]] olyan elrendezéseket eredményez, amelyek nem esnek egybe a naiv számítással, és nem minden fordító alkalmazza ugyanúgy.

Gyakorlati következmény: shared library határon akkor is érdemes kerülni a C++ típusok közvetlen átadását, ha is_trivial és is_standard_layout igaz. A stabil minta az opaque handle — átlátszatlan mutató vagy egész azonosító, amelyet a könyvtár belső típusra kovácsol —, amit a Stabil shared library határ és verziózás (36.3) részletez. Egy tanulságos eset a libstdc++ _GLIBCXX_USE_CXX11_ABI váltása (GCC 5): két különböző értékkel épített modul ugyanarra a szimbólumra más std::string layout-ot használ, ezért a keveredés linkelési hibát vagy csendes memóriahibát okoz. Ez nem szabványkérdés, hanem egy konkrét standard library ABI-döntése.

36.1.6 Kidolgozott példa: ugyanaz a program két nézetben

Fordítsuk le a fenti Widget-példát egy önálló fájlba, és figyeljük meg a szimbólumokat.

// shapes.cpp — GCC 13 / Clang 17, Linux x86-64, Itanium ABI
#include <cstdio>

struct BaseA {
    virtual ~BaseA() = default;
    virtual void render() const { std::puts("A::render"); }
    int ax = 1;
};
struct BaseB {
    virtual ~BaseB() = default;
    virtual void render() const { std::puts("B::render"); }
    int bx = 2;
};
struct Widget : BaseA, BaseB {
    ~Widget() override = default;
    void render() const override { std::puts("Widget::render"); }
    int w = 3;
};

int main() {
    Widget w;
    BaseB* b = &w;
    b->render();
    std::printf("sizeof(Widget)=%zu\n", sizeof(Widget));
    std::printf("offsets: A=%td B=%td\n",
        reinterpret_cast<const char*>(static_cast<BaseA*>(&w)) - reinterpret_cast<const char*>(&w),
        reinterpret_cast<const char*>(b) - reinterpret_cast<const char*>(&w));
}

Fordítás és megfigyelés:

g++ -std=c++20 -O0 -g -o shapes shapes.cpp
nm shapes | grep -E 'ZTV|ZTI|ZTS|render' | c++filt   # demangle-özött lista
readelf -Ws shapes | grep -E '_ZTV|_ZTI'             # nyers szimbólumok

A megfigyelhető jelenségek (konkrét értékek a fenti profilon, más cél-ABI-n eltérhetnek):

Ez a kísérlet jól mutatja, hogy a nyelvi objektum és az ABI-reprezentáció két külön szint: a forrásban egy render függvény van, a binárison belül viszont két-három szimbólum (valódi, thunk, vtable-bejegyzés) írja le ugyanazt a viselkedést.

36.1.7 Tipikus hibák

36.1.8 Feladatok

1. (magyarázó) Adott a _ZNK4Data5mergeERKS_ szimbólum. Fejtse vissza a mangling sémát kézzel, és nevezze meg, milyen szintaktikai elemeknek felelnek meg az egyes karakterek.

2. (kódolási) Fordítson le egy három bázisú öröklést (class D : public A, public B, public C) használó programot a fenti profillal, és nm -C kimenetéből azonosítsa, mely render-szerű hívásoknál jelenik meg thunk. Módosítsa úgy a bázisokat, hogy az egyik virtuális bázis legyen (virtual public A), és hasonlítsa össze a thunkok számát és típusát.

3. (diagnosztikai) Adott egy bináris, amely undefined reference to __cxa_throw hibát ad egy tiszta C program linkelésekor. Mi a hiba oka, és hogyan orvosolható a build (a -fno-exceptions és a -static kapcsolókhoz kapcsolódó viselkedést is érintve)?

36.1.9 Megoldások

1. A _ZNK4Data5mergeERKS_ bontása: _Z a mangling prefix; N „nested name” (a név névtérben/osztályban van); K const tagfüggvény; 4Data a Data osztály neve (hossz + karakterek); 5merge a tagfüggvény neve; E a nested name lezárása; R referencia (T&); K a hivatkozott típus const; S_ a korábbi helyettesítő (S_ az első helyettesítő elemre utal, itt magára a Data-ra). A függvény tehát: void vagy egyéb visszatérési típusú Data::merge(const Data&) const. Fontos, hogy a visszatérési típus nincs a manglingben (kivéve sablon-függvényeket és operátorokat), mert a túlterhelést csak a paraméterek és a tagfüggvény-jelleg különbözteti meg.

2. A megoldás kulcsa, hogy a D első bázis-részobjektuma 0. offseten van, a második és harmadik bázisé nem. Minden olyan virtuális függvény, amelyet a B vagy C részobjektumon keresztül hívunk, thunkot generál (legalább non-virtual thunk to D::render). Ha az A-t virtuális bázissá tesszük, a thunkok virtual thunk-ká alakulnak, mert a this-korrekció nem egy statikus offset, hanem a virtuális bázis vptr-jéből nyert adat alapján történik. Ennek megfelelően a vtable szerkezete bonyolultabb (VTT — virtual table table), és a thunkok száma nő. Ellenőrzési feltétel: az nm -C kimenetében a thunk szót tartalmazó bejegyzések kiírandók, és a két változat között a módosítás kimutatható különbséget ad. (Konkrét darabszám erősen függ a fordító verziójától és a -O szinttől.)

3. A __cxa_throw (és társai, __gxx_personality_v0) a libstdc++-ban él. Ha C programot -static-kal vagy -fno-exceptions-szel linkelünk, de a program — például egy headerben lévő inline C++ kód vagy egy #include <stdexcept> — mégis hivatkozik ezekre, a linker nem találja a szimbólumot. Javítás: (a) a C++ kódot izoláljuk, és extern "C" wrapperben tartsuk, vagy (b) kapcsoljuk be a libstdc++-t (g++-vel linkeljük a vegyes programot), vagy (c) -fno-exceptions esetén biztosítsuk, hogy semmilyen C++ komponens ne dobjon kivételt, és a standard library fejléceit se hívjuk olyan útvonalon, amely throw-t igényel. -static esetén a --gc-sections segíthet, de a libgcc_eh.a-t és libstdc++.a-t is be kell vonni. Ellenőrzési feltétel: nm -D a.out | grep __cxa_ a linkelés után vagy üres, vagy a bináris statikusan tartalmazza a szükséges szimbólumokat; ldd a.out-ból kiderül, hogy melyik runtime-ra támaszkodik.

36.1.10 Összefoglaló

A C++ ABI — konkrét profilon: Itanium C++ ABI Linux x86-64-en, GCC 13/Clang 17, -std=c++20 — három rétegben terjeszti ki a C-szerződést. A névtorzítás a nyelvi entitást egyedi szimbólumnévvé kódolja; a séma nem szabvány, hanem ABI-döntés, és a c++filt/nm -C segítségével olvasható. A layout a nem polimorf osztályoknál közel C-struct, a polimorfaknál viszont rejtett vptr-t és vtable-t vezet be, benne offset-to-top, RTTI-pointer és függvénypointerek; a többszörös (és virtuális) öröklés thunkokat generál a this-korrekcióhoz. A runtime-támogatás (.eh_frame, .gcc_except_table, __cxa_*, _Unwind_*) biztosítja a kivételkezelést és a statikus inicializációt — ezek nem átjárhatók szabadon extern "C" határon. Végül a is_standard_layout/is_trivial nyelvi tulajdonságok hasznos közelítések, de nem ABI-stabilitási garanciák; stabil shared library határhoz opaque handle és C-kompatibilis interfész kell, amit a következő alfejezetek (extern C és két nyelvű header (36.2), Stabil shared library határ és verziózás (36.3), Python és más nyelvek FFI-kapcsolata (36.4)) részleteznek. A laborban a nm, readelf és c++filt együttes használata adja a megfigyelés eszközét — a konkrét kimenet azonban mindig a rögzített toolchainprofilhoz kötendő.