A plugin alapú rendszerekben a host és a plugin jellemzően külön fordítási egységként, gyakran külön időpontban, külön toolchainnel és külön kiadási ciklusban készül. A kettő között futásidőben létrejövő kapcsolat nem forráskód-szintű, hanem bináris: a dinamikus linker által feloldott szimbólumok, a hívási konvenció, a típusok memórialayoutja, a kivételkezelés mechanizmusa és az erőforrás-tulajdonlás együttesen alkotja azt a szerződést, amelyet ABI-szerződésnek (application binary interface contract) nevezünk. Ez az alfejezet azt mutatja meg, hogyan lehet ezt a szerződést tudatosan kicsire és verziózhatóvá tenni, és milyen kompatibilitási kockázatok maradnak akkor is, ha a határon csak C típusok mennek át.
A tárgyalás egy konkrét GCC/Clang és libstdc++/libc++ laborprofilhoz kötött. Ahol a viselkedés implementációfüggő, azt jelöljük; a fejezet nem tartalmaz ellenőrzött szabványidézetet, a konkrét verziókat és kapcsolókat a laborprofil rögzíti.
Két különböző fogalmat kell szétválasztani. Forráskompatibilitásról (source compatibility) akkor beszélünk, ha a kliens forráskódja változtatás nélkül újrafordítható a könyvtár új verziójával. Bináris kompatibilitásról (binary compatibility) akkor, ha egy korábban lefordított kliens bináris változatlanul működik az új könyvtárral — vagy fordítva, egy korábban lefordított könyvtár az új klienssel.
A két fogalom nem fedi egymást, és egyik sem következik a másikból.
Egy osztály végére felvett új virtuális függvény forráskompatibilis
lehet, de a vtable layoutját megváltoztatva binárisan tör. Egy adattag
hozzáadása szintén forráskompatibilis lehet, miközben a
sizeof és az offsetek megváltoznak. Fordítva: egy szimbólum
átnevezése binárisan semleges lehet, ha a régi nevet megtartó
kompatibilitási szimbólum megmarad, forráskompatibilis viszont nem.
A bináris kompatibilitás mindig két konkrét build között, adott compiler-, stdlib-, flag- és targetprofil mellett értelmezhető. Nincs olyan állítás, hogy „a C++ ABI stabil”; a gyakorlatban az Itanium C++ ABI az egyik de facto leírás, de annak követését és részleteit a konkrét toolchain dokumentációja rögzíti, nem a nyelvi szabvány. A C határ ezzel szemben jóval kisebb felületet tesz ki: nincs névtorzítás, nincs vtable, de a struct-layout, a hívási konvenció és az allokátor továbbra is szerződéselem marad.
A legfontosabb felismerés, hogy a bináris határ nem csak a forráskódból áll, hanem a fordítási környezetből is. Az alábbi tényezők mindegyike önállóan képes arra, hogy két, látszólag azonos forrásból készült binárist inkompatibilissé tegyen:
_GLIBCXX_DEBUG
kapcsolója ellenőrzött iterátorokat és eltérő belső layoutot eredményez,
ami a típusok méretét és a szimbólumneveket is érintheti. A debug és a
release build objektumai nem keverhetők a határon._GLIBCXX_USE_CXX11_ABI kapcsolója két, egymással nem
kompatibilis std::string/std::list ABI-t tart
fenn. A két oldalnak ugyanazt kell választania.-fno-exceptions és -fno-rtti önmagukban
jellemzően nem változtatják meg a layoutot, de a kivételátadás a határon
csak kompatibilis unwinderrel működik, RTTI nélkül pedig a
dynamic_cast és a typeid nem használható a
másik oldal típusain.-std= váltás bizonyos
esetekben a függvénytípus részévé tett tulajdonságokat (például a
noexcept-ot) és ezen keresztül a névtorzítást is érintheti;
ezt a konkrét toolchain dokumentációja alapján kell ellenőrizni.A védekezés nem az, hogy minden kapcsolót egyformára kényszerítünk, hanem hogy a határt olyan konstrukciókra szűkítjük, amelyekre ezek a tényezők nem hatnak.
A leggyakoribb hiba std::string,
std::vector vagy más STL-típus átadása a modulhatáron.
Ennek három különálló kockázata van. Az első a layout:
ha a két oldal eltérő stdlib-verziót vagy debug módot használ, a típus
mérete és belső elrendezése eltér, és a másik oldal a saját (rossz)
értelmezésével olvassa a memóriát. A második az
allokátor: ha az egyik modul allokálja a puffert és a
másik szabadítja fel, a felszabadítás csak akkor helyes, ha mindkét
oldal ugyanazt az allokátort használja. Eltérő malloc
implementáció (például egy másik allocatorral linkelt plugin) esetén ez
memóriahiba. A harmadik a tulajdonlás: a határon
átadott objektum élettartama és felszabadítási felelőse nem derül ki a
típusból.
A mérnöki szabály ezért az, hogy a C határon ne menjen át STL-típus,
és az allokációs szimmetria érvényesüljön: aki allokál, az
szabadít fel. A host által átadott buffer tulajdonosa a host
marad, a plugin által létrehozott objektumot a plugin egy
destroy függvénye szabadítja fel. Ez a szabály akkor is
érvényes, ha a két oldal látszólag ugyanazt a runtime-ot használja, mert
a látszat a következő frissítésnél megváltozhat.
Az opaque handle (átlátszatlan fogantyú) lényege, hogy a header csak a típus nevét deklarálja, a layoutot nem:
typedef struct plugin_handle plugin_handle; /* a layout a .c fájlban él */A kliens így csak pointert tárol, nem függ a mérettől és a mezőktől, a könyvtár pedig szabadon változtathatja a belső reprezentációt. Ez a bináris stabilitás legegyszerűbb eszköze.
A második eszköz a méret- és verziómező. Minden
határon átadott struktúra első mezői a struct_size és az
abi_version, amelyeket a fogadó fél ellenőriz. A
struct_size lehetővé teszi a bővítést: az új mezőket a
struktúra végére kell felvenni, a régi kliens kisebb méretet ad, a
könyvtár pedig csak addig olvas, ameddig a méret ér. A fogadó oldal
ellenőrzése:
ahol az utolsó olyan mező, amelyet a fogadó fél használni kíván. Ha a feltétel nem teljesül, a mező nem olvasható.
A harmadik eszköz a callback struktúra:
függvénypointerek és egy void *context egyetlen, verziózott
struktúrában. A context élettartama a regisztráló fél felelőssége; a
könyvtár nem tarthatja meg a visszahívás után, és a hostnak a leállítás
előtt törölnie kell a regisztrációt.
A dinamikus linker a SONAME (shared object name)
alapján azonosítja a könyvtárat. A szokásos séma: a valódi fájl
libdemo.so.1.2.3, a SONAME libdemo.so.1, a
fejlesztői symlink libdemo.so. A SONAME váltása
(libdemo.so.1 → libdemo.so.2) jelzi a bináris
inkompatibilitást, és lehetővé teszi, hogy a régi és az új verzió
egyszerre legyen jelen a rendszeren. A SONAME-en belüli finomabb
verziózásra a szimbólumverziózás szolgál (version
script, .symver); ez GNU linker kiterjesztés, nem
POSIX-szabvány, ezért használatát a laborprofilban rögzíteni kell.
A szimbólumláthatóság (symbol visibility)
alapértelmezésben minden nem statikus szimbólumot exportál, ami
névütközésekhez és véletlen ABI-felülethez vezet. A
-fvisibility=hidden fordítási kapcsolóval csak az explicit
módon megjelölt szimbólumok maradnak exportáltak; C++-ban ehhez
jellemzően -fvisibility-inlines-hidden is társul. A
megjelölés GNU/Clang esetén
__attribute__((visibility("default"))), ami nem hordozható
minden compilerre.
A PImpl (pointer to implementation) minta a C++
oldalon ugyanazt a célt szolgálja, mint az opaque handle a C oldalon: a
header csak egy pointert tartalmaz a privát implementációra, így a
privát tagok változása nem változtatja meg a nyilvános osztály méretét.
Költsége egy indirekció és jellemzően egy allokáció; a destruktort a
.cpp fájlban kell definiálni, ahol a privát típus
teljes.
A dlclose nem egyszerű memóriafelszabadítás: a könyvtár
kódjára mutató minden pointer, a benne létrehozott objektum, a
regisztrált callback és a coroutine frame érvénytelenné válik. A
dinamikus linker nem garantálja, hogy a könyvtár statikus destruktorai
minden esetben lefutnak, és azt sem, hogy a könyvtár kódja a
dlclose után bármikor biztonságosan hívható. A helyes
sorrend ezért:
stop/shutdown függvénye
leállítja a szálakat és a coroutine-okat;destroy felszabadítja a plugin által allokált
objektumokat;dlclose.A coroutine külön figyelmet érdemel: a frame resumption pointert
tartalmazhat a plugin kódjára, ezért ha a host a dlclose
után folytatja a coroutine-t, az nem definiált viselkedés. A frame-et a
plugin allokátorával és a plugin kódjával kell felszabadítani, még a
dlclose előtt; erről részletesebben a Dangling capture, resumption és cancellation (35.3)
szól.
A példa egy minimális, verziózott C határt mutat. A host C++20-ban, a plugin C17-ben készül; a határon csak C típusok és opaque handle mennek át.
/* plugin_api.h */
#ifndef PLUGIN_API_H
#define PLUGIN_API_H
#include <stdint.h>
#if defined(__GNUC__) || defined(__clang__)
# define PLUGIN_API __attribute__((visibility("default")))
#else
# define PLUGIN_API
#endif
#ifdef __cplusplus
extern "C" {
#endif
#define PLUGIN_ABI_VERSION 1u
typedef struct plugin_handle plugin_handle; /* opaque */
typedef struct plugin_host {
uint32_t struct_size; /* = sizeof(plugin_host) a hívó oldalán */
uint32_t abi_version; /* = PLUGIN_ABI_VERSION */
void *context;
void (*log)(void *context, int level, const char *msg);
} plugin_host;
typedef struct plugin_vtable {
uint32_t struct_size;
uint32_t abi_version;
const char *(*name)(void);
plugin_handle *(*create)(const plugin_host *host);
int (*start)(plugin_handle *h);
void (*stop)(plugin_handle *h);
void (*destroy)(plugin_handle *h);
} plugin_vtable;
PLUGIN_API const plugin_vtable *plugin_init(uint32_t host_abi_version);
#ifdef __cplusplus
}
#endif
#endif/* plugin.c */
#include "plugin_api.h"
#include <stddef.h>
#include <stdlib.h>
struct plugin_handle {
const plugin_host *host;
int running;
};
static const char *impl_name(void) { return "demo-plugin"; }
static plugin_handle *impl_create(const plugin_host *host) {
if (host == NULL) return NULL;
if (host->struct_size < offsetof(plugin_host, log) + sizeof(host->log))
return NULL;
if (host->abi_version != PLUGIN_ABI_VERSION) return NULL;
plugin_handle *h = malloc(sizeof *h);
if (h == NULL) return NULL;
h->host = host;
h->running = 0;
return h;
}
static int impl_start(plugin_handle *h) {
if (h == NULL) return -1;
h->running = 1;
if (h->host->log != NULL) h->host->log(h->host->context, 1, "started");
return 0;
}
static void impl_stop(plugin_handle *h) {
if (h == NULL) return;
h->running = 0;
if (h->host->log != NULL) h->host->log(h->host->context, 1, "stopped");
}
static void impl_destroy(plugin_handle *h) { free(h); }
static const plugin_vtable demo_vtable = {
.struct_size = sizeof(plugin_vtable),
.abi_version = PLUGIN_ABI_VERSION,
.name = impl_name,
.create = impl_create,
.start = impl_start,
.stop = impl_stop,
.destroy = impl_destroy,
};
PLUGIN_API const plugin_vtable *plugin_init(uint32_t host_abi_version) {
if (host_abi_version != PLUGIN_ABI_VERSION) return NULL;
return &demo_vtable;
}// host.cpp
#include "plugin_api.h"
#include <dlfcn.h>
#include <cstdio>
namespace {
void host_log(void *ctx, int level, const char *msg) {
const auto *tag = static_cast<const char *>(ctx);
std::fprintf(stderr, "[%s/%d] %s\n", tag, level, msg);
}
} // namespace
int main(int argc, char **argv) {
if (argc != 2) {
std::fprintf(stderr, "usage: %s <plugin.so>\n", argv[0]);
return 2;
}
void *lib = dlopen(argv[1], RTLD_NOW | RTLD_LOCAL);
if (!lib) { std::fprintf(stderr, "dlopen: %s\n", dlerror()); return 1; }
using init_fn = const plugin_vtable *(*)(uint32_t);
auto *init = reinterpret_cast<init_fn>(dlsym(lib, "plugin_init"));
if (!init) {
std::fprintf(stderr, "dlsym: %s\n", dlerror());
dlclose(lib);
return 1;
}
const plugin_vtable *vt = init(PLUGIN_ABI_VERSION);
if (!vt || vt->struct_size < sizeof(plugin_vtable)
|| vt->abi_version != PLUGIN_ABI_VERSION) {
std::fprintf(stderr, "incompatible plugin ABI\n");
dlclose(lib);
return 1;
}
static char host_tag[] = "host";
plugin_host host{};
host.struct_size = sizeof(host);
host.abi_version = PLUGIN_ABI_VERSION;
host.context = host_tag;
host.log = host_log;
plugin_handle *h = vt->create(&host);
if (!h) { dlclose(lib); return 1; }
int rc = vt->start(h);
std::printf("plugin=%s rc=%d\n", vt->name(), rc);
vt->stop(h);
vt->destroy(h); // objektum élettartamának lezárása
vt = nullptr; // a vtable-re mutató pointer is invalidálódik
dlclose(lib);
return rc == 0 ? 0 : 1;
}A javasolt buildparancsok (a tanuló laborjában futtatandók, a labour profilhoz igazítva):
gcc -std=c17 -O2 -fPIC -fvisibility=hidden -shared \
-Wl,-soname,libdemo.so.1 \
-o libdemo.so.1.0.0 plugin.c
ln -sf libdemo.so.1.0.0 libdemo.so.1
ln -sf libdemo.so.1 libdemo.so
g++ -std=c++20 -O2 -Wall -Wextra -o host host.cpp -ldl
./host ./libdemo.so.1A példa három tanulsága: a struct_size és az
abi_version ellenőrzése a plugin oldalán történik, mielőtt
bármely mezőt olvasna; a plugin_init a
PLUGIN_API miatt exportált, minden más szimbólum rejtett; a
destroy és a dlclose sorrendje biztosítja,
hogy a plugin kódjára a könyvtár eltávolítása után ne maradjon
hivatkozás. A kód a bemutatott formában nem lett lefuttatva; a
viselkedés a konkrét toolchainen ellenőrizendő.
A leggyakoribb hiba az STL-típus átadása a határon. Javítás:
const char * + hossz, vagy opaque handle, és a
felszabadítás a tulajdonos oldalán. A második a struct_size
ellenőrzésének elhagyása: a régi plugin olvassa az új mezőt, vagy
fordítva. Javítás: minden határon átadott struktúra első mezője a méret,
és a fogadó fél a fenti egyenlőtlenséggel ellenőriz. A harmadik a
dlclose a destroy előtt: a plugin kódjára
mutató pointer dangling lesz. Javítás: a fenti négy lépéses sorrend
betartása. A negyedik a -fvisibility=hidden elhagyása:
minden szimbólum exportált lesz, ami névütközéshez vezethet. Javítás:
rejtett alapértelmezés és explicit export. Az ötödik a SONAME elhagyása:
a linker a fájlnevet jegyzi meg, és a frissítés után a dinamikus linker
nem találja a könyvtárat.
Magyarázó feladat. Különböztesd meg a forrás- és a bináris kompatibilitást, és mondj egy-egy olyan változtatást, amely az egyiket töri, a másikat nem.
Kódolási feladat. Bővítsd a
plugin_host struktúrát egy új, opcionális warn
callback-kel úgy, hogy egy régi host binárisa változatlanul működjön az
új pluginnal, és egy régi plugin binárisa az új hosttal.
Diagnosztikai feladat. A plugin frissítése után
a host a create hívásban szegmentációs hibával leáll.
Milyen okokat vizsgálsz, és milyen sorrendben?
Élettartam-feladat. A plugin egy coroutine-t
indít, amelyet a host eseményhurok folytat. Mit kell tenni a
dlclose előtt, és hogyan ellenőrzöd, hogy nem maradt a
plugin kódjára mutató hivatkozás?
1. Megoldás. A forráskompatibilitás a forrás
újrafordíthatóságát jelenti, a bináris kompatibilitás a már lefordított
bináris változatlan működését. Példa forráskompatibilis, de binárisan
törő változtatásra: egy adattag hozzáadása egy nyilvános struktúrához (a
forrás újrafordul, de a sizeof és az offsetek
megváltoznak). Példa binárisan semleges, de forráskompatibilitást törő
változtatásra: egy nyilvános függvény átnevezése, miközben a régi nevet
megtartó kompatibilitási szimbólum megmarad. Ellenőrzési
feltétel: a válasz nevezze meg a két fogalom különbségét, és a
példákban mutassa meg, melyik oldal fordul újra.
2. Megoldás. A warn mezőt a
plugin_host végére kell felvenni, és a plugin a
struct_size alapján dönt: csak akkor hívja meg, ha
A régi host kisebb struct_size-t ad, ezért a plugin nem
hívja a warn-ot; a régi plugin nem ismeri a
warn mezőt, ezért nem olvassa. A host oldalán a
warn pointert a plugin_host inicializálásakor
kell beállítani, és a hostnak a dlclose előtt törölnie kell
a regisztrációt. Ellenőrzési feltétel: a régi host binárisa
változatlanul fusson az új pluginnal, és az új host a régi pluginnal; a
warn csak akkor hívódjon meg, ha mindkét oldal ismeri.
3. Megoldás. Elsőként a struct_size és
az abi_version ellenőrzését kell megvizsgálni: hiányzik-e a
plugin oldalán, vagy a host rossz értéket ad. Ezután a vtable layoutját:
nem került-e új függvény a meglévők közé (a végére felvett új függvény a
régi hostot nem töri, a közbeszúrt igen). Ezután a betöltött könyvtár
azonosságát: a host a régi SONAME-jú fájlt tölti-e be. Végül a
szimbólumütközést: a plugin -fvisibility=hidden nélkül
exportált-e azonos nevű szimbólumot. Ellenőrzési feltétel: a
vizsgálat nevezze meg a struct_size-ellenőrzés hiányát mint
leggyakoribb okot, és adjon rá konkrét javítást.
4. Megoldás. A dlclose előtt a plugin
stop/shutdown függvényének be kell fejeznie
vagy fel kell szabadítania a coroutine-t a plugin allokátorával és
kódjával; a hostnak törölnie kell a coroutine-handle-t és a
callbackregisztrációkat; a plugin szálait joinolni kell. A
dlclose után a host nem tarthat a plugin kódjára mutató
pointert. Ellenőrzési feltétel: a plugin shutdown
függvénye adja vissza a nyitott erőforrások számát, és ez a
dlclose előtt nulla legyen; a folyamat sanitizerrel
(például AddressSanitizerrel) fusson a laborban.
A stabil shared library határ nem egyetlen trükk, hanem szerződés: a
bináris kompatibilitás mindig konkrét compiler-, stdlib- és
flagprofilhoz kötött, ezért a határt a lehető legkisebbre kell
szűkíteni. A C típusok, az opaque handle, a
struct_size/abi_version mezők, a verziózott
callback struktúrák, a SONAME és a rejtett szimbólumláthatóság együtt
csökkentik a bináris couplingot. Az STL-típusok és az allokátorok
átadása a határon továbbra is kockázat; a szabály az allokációs
szimmetria és a „csak C típus a határon”. A dlclose nem
takarít automatikusan: a plugin objektumainak, callbackjeinek és
coroutine-jainak élettartamát a dlclose előtt explicit
módon le kell zárni.