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.
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.
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.
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.
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:
.eh_frame (és .eh_frame_hdr) — a CFI (Call
Frame Information), amely leírja, hogy az unwinder hogyan állítsa vissza
a regisztereket lépésenként..gcc_except_table — az LSDA (Language-Specific Data
Area), amely megmondja, hogy az adott programszámlálás-tartományban
melyik catch blokk illetékes, és milyen típushoz.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.
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:
sizeof-szal rendelkezhet.[[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.
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ólumokA megfigyelhető jelenségek (konkrét értékek a fenti profilon, más cél-ABI-n eltérhetnek):
_ZTV6Widget három bejegyzést tartalmaz:
offset-to-top, typeinfo, majd a
BaseB-részobjektumban a render-re mutató
thunk._ZTI6Widget az RTTI-leíró, _ZTS6Widget a
típusnév.nm -C listájában megjelenik a
non-virtual thunk to Widget::render() const bejegyzés — ez
a this-korrekciót végző stub, amely a BaseB
vtable-jében szerepel.sizeof(Widget) és az offsetek a fenti elrendezés
szerintiek, de ezeket a konkrét cél-ABI és a -O0 mellett
kell értelmezni; -O2 nem változtatja meg a layoutot, de
befolyásolhatja a thunk jelenlétét vagy a hívások inline-olását.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.
extern "C" határon.
Még ha egy adott napon működik is, nincs szerződés mögötte, és a
viselkedés a következő libgcc-verzióval megváltozhat.typeid-re épülő DSO-azonosítás. A
típusegyesítés a dynamic linker konfigurációjától függ;
-fvisibility=hidden és explicit
__attribute__((visibility("default"))) nélkül a határ nem
kontrollált.-fpack-struct, -mabi=,
-D_GLIBCXX_USE_CXX11_ABI= vagy cél-ABI-t használ.extern "C"
callback-et kísérő void* context pointerrel.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)?
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.
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ő.