36.3 Stabil shared library határ és verziózás

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.

36.3.1 Source- és binary kompatibilitás

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.

36.3.2 A kompatibilitást rontó fordítási tényezők

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:

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.

36.3.3 STL objektumok és allocatorok a modulhatáron

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.

36.3.4 Opaque handle, méret és verzió, callback struktúrák

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:

struct_size≥offsetof(T,m)+sizeof(m) \text{struct\_size} \ge \operatorname{offsetof}(T, m) + \operatorname{sizeof}(m)

ahol mm 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.

36.3.5 SONAME, szimbólumláthatóság és PImpl

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.

36.3.6 A dlclose és az élettartam lezárása

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:

  1. a plugin saját stop/shutdown függvénye leállítja a szálakat és a coroutine-okat;
  2. a destroy felszabadítja a plugin által allokált objektumokat;
  3. a host törli a callbackregisztrációkat, és nem tart meg a pluginra mutató pointert;
  4. csak ezután következik a 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.

36.3.7 Kidolgozott példa: verziózott plugin API

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.1

A 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ő.

36.3.8 Tipikus hibák és javításuk

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.

36.3.9 Feladatok

  1. 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.

  2. 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.

  3. 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?

  4. É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?

36.3.10 Megoldások

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

host->struct_size≥offsetof(plugin_host,warn)+sizeof(host->warn). \text{host->struct\_size} \ge \operatorname{offsetof}(\text{plugin\_host}, \text{warn}) + \operatorname{sizeof}(\text{host->warn}).

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.

36.3.11 Összefoglaló

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.