Az FFI (foreign function interface) az a mechanizmus, amelyen keresztül egy nyelvi runtime egy másik, natív kódot hív, illetve abból adatot fogad. DevOps-környezetben ez nem egzotikus eset: platform-eszközök íródnak Pythonban egy C++ mag köré, megfigyelő ágensek töltenek be natív bővítményt, CLI-k hívnak meg egy C ABI-n exportált gyorsított könyvtárat, és egyre gyakrabban épül Python-csomag egy C++ motorra. Az ilyen rendszerek hibái ritkán nyelvi szintaktikai hibák: jellemzően egy pointer élettartama, egy buffer tulajdonjoga vagy egy callback szálkontextusa nem illeszkedik a két oldal feltevéseihez.
Ez az alfejezet ezért nem a binding-könyvtárak használatának mikéntjét tanítja, hanem azt a szerződést, amelyet minden ilyen határon rögzíteni kell. A C ABI-t tekintjük a találkozási pontnak: a korábbi Stabil shared library határ és verziózás részben rögzített exportált felület itt kapja meg a másik nyelv felőli értelmezését. A buffer- és nézet-szerződéshez a span, byte és bounds-szerződések, a szövegátadáshoz a Szövegkódolás, locale és UTF-8 C-ben rész definícióit használjuk fel; azokat itt nem ismételjük meg, csak a határra vonatkozó következményeiket.
Definíció. Az FFI egy adott nyelvi runtime és egy másik, jellemzően C ABI-t biztosító natív kód közötti hívási és adatátadási mechanizmus, amelyet a hívó oldal runtime-specifikus binding-rétege valósít meg. A binding-réteg (ctypes, cffi, pybind11, JNI, cgo) nem szünteti meg a határt, csak lefordítja rá a két oldal típusait.
Előfeltételek. A határon átvihető felületre egyszerre öt feltételnek kell teljesülnie:
extern "C" linkage-dzsel exportált függvények és
fix layoutú, C-kompatibilis típusok szerepelnek a határon.*_destroy / *_release függvénye, és a fogadó
oldal garantálja annak meghívását.A 3. pont a leggyakrabban megsértett feltétel, ezért érdemes minden határfelülethez tulajdonlási táblát írni. Egy tipikus minta:
| Erőforrás | Tulajdonos a C oldalon | Tulajdonos a fogadó oldalon | Felszabadítás |
|---|---|---|---|
| bemeneti byte-buffer | nincs (kölcsönzött) | a hívó (Python bytes/bytearray) |
a fogadó GC-je |
| callback thunk / függvénypointer | nincs | a fogadó (a CFUNCTYPE objektum) |
a fogadó GC-je, de csak deregisztráció után |
opaqu handle (struct ds_ctx*) |
a könyvtár allokálja | a fogadó wrapper tartja | ds_ctx_destroy |
| hibaüzenet-sztring | a könyvtár statikus puffere | nincs | nem szabad felszabadítani |
A Python felől két alapvető megközelítés van. A
ctypes futásidőben tölti be a shared libraryt, és a
hívás helyén deklarált argtypes/restype
alapján konvertál: nincs fordítási lépés, de a típushelyesség csak a
hívás pillanatában derül ki. A cffi két módot kínál:
ABI-módban ugyanúgy futásidőben dolgozik, API-módban viszont a
deklarációból C-bővítményt generál, így a fejlécet a fordító ellenőrzi —
ez a fejléc és a binding szinkronját erősíti, de a mögöttes hívás
továbbra is a C ABI-n megy át.
Mindkét esetben ugyanaz a tervezési következtetés: a C++ oldalon
érdemes egy vékony, kizárólag C típusokat használó wrappert exportálni,
nem a C++ osztályokat közvetlenül kitenni. Ennek nem csak a névtorzítás
az oka (azt a Névtorzítás, layout és C++ runtime
ABI rész tárgyalta), hanem az, hogy a wrapper az egyetlen hely, ahol
a hibakonverzió, a noexcept-garancia és a
paraméter-ellenőrzés egy ponton tartható.
A natív felület soha ne const char*-ban hordozza a
hosszt, ha a bemenet bináris vagy UTF-8 lehet. A helyes minta
(const uint8_t* data, size_t len), ahol a szerződés:
data == NULL pontosan akkor megengedett, ha
len == 0;[data, data+len) tartományt csak a hívás
idejére olvashatja;A C++ oldalon ezt természetesen fejezi ki a
std::span<const std::byte> (illetve
std::span<const std::uint8_t>) kölcsönzött nézet,
feltéve, hogy a span nem kerül ki a hívás élettartamából. A Python
oldalon a buffer objektumnak a teljes hívás alatt élnie kell. CPython
esetén a bytes argumentumként átadva a konverzió idejére
referenciát tart, így a hívás alatt nem szabadul fel; ez
runtime-specifikus garancia, ezért dokumentálni kell, és nem szabad rá
építeni más implementációnál. Ha a natív kód a hívás után is hivatkozna
a bufferre, az a klasszikus dangling eset: a javítás vagy mély másolás a
C oldalon, vagy egy explicit, a fogadó oldalon életben tartott holder
objektum.
Python str-t soha nem adunk át közvetlenül: a kódolás
ilyenkor a konverziótól függ, és nem az, amit a natív oldal feltételez.
A helyes út: a fogadó oldalon s.encode("utf-8"), és a natív
oldal hosszal dolgozik. Ezzel együtt jön a beágyazott NUL problémája: ha
a C oldal strlen-t használ, a b"a\x00b"
bemenet két byte után megáll, holott hét byte érkezett. A hosszátadó API
ezt kiküszöböli, de a dokumentációnak ki kell mondania, hogy a NUL
közönséges byte, nem lezáró.
A méretkonverzió külön ellenőrzést igényel. Python
int-ről size_t-re váltva a feltétel
,
ahol a SIZE_MAX az adott adatmodelltől függ (LP64 alatt 64
bites). ctypes-ban a c_size_t a cél architektúra szerint
konvertál, de a tartományellenőrzést nem pótolja: negatív vagy túl nagy
értéknél a hívás előtt explicit ellenőrzés kell. A határon használt
típusok megfeleltetése:
| C típus | ctypes típus | Megjegyzés |
|---|---|---|
const uint8_t* + size_t |
POINTER(c_uint8) + c_size_t |
nem NUL-terminált, hossz kötelező |
size_t |
c_size_t |
előjel nélküli, szélessége adatmodell-függő |
uint64_t* (out) |
POINTER(c_uint64) |
a hívó birtokolja, a hívás előtt allokált |
int (hibakód) |
c_int |
0 = siker, negatív = hiba |
const char* (statikus) |
c_char_p |
csak olvasás, nem szabad felszabadítani |
A kódszám és a byte-szám kapcsolatára egy hasznos vázlat: UTF-8-ban a
folytató byte-ok 10xxxxxx alakúak, a többi byte
kódpont-kezdő, ezért a kódpontok száma nem haladhatja meg a byte-számot,
azaz
.
Ez vázlat, nem teljes bizonyítás: a felülbecslésből adódó különbség
éppen a többszörös hosszúságú sorozatok száma. A gyakorlati
következtetés az, hogy a natív API byte-ban számoljon, a kódpontszámot
pedig csak akkor adja vissza, ha a szerződés ezt kéri.
A callback a legkényesebb pont, mert két élettartamot és két
szálkontextust kapcsol össze. A natív oldal csak egy
void* user és egy függvénypointert lát; arról semmit nem
tud, hogy a mögötte lévő Python callable mikor szabadul fel, és melyik
szálon hívható.
Élettartam. A fogadó oldalnak a regisztráció teljes
ideje alatt életben kell tartania a CFUNCTYPE objektumot,
mert az hordozza a C thunkot. Ha a Python objektum GC-elődik, a natív
oldalon marad egy dangling függvénypointer. A javítás: a wrappert egy
osztály fogja össze, amely a CFUNCTYPE példányt
attribútumként tartja, és a release() a natív
deregisztráció után engedi csak el.
Szál és interpreter. CPython esetén a Python C API-t
csak a GIL birtokában szabad hívni, és a natív szálon futó callbackhez
előbb csatlakozni kell az interpreterhez (például
PyGILState_Ensure/Release párossal). Ha a hívó
Python szál a GIL birtokában blokkol egy hosszú C híváson, miközben a C
könyvtár egy másik szálon próbál callbacket indítani, a callback nem tud
GIL-hez jutni. Vázlat a holtpont feltételére: legyen
a Python szál, amely a GIL-t tartva vár a C hívás visszatérésére, és
a C könyvtár szálja, amely a callback futtatásához GIL-t kér. Ha
a várakozás előtt nem engedte el a GIL-t,
nem tud előrehaladni,
pedig nem tud visszatérni: körkörös várakozás. A javítás vagy a GIL
elengedése a blokkoló C hívás körül
(Py_BEGIN_ALLOW_THREADS/Py_END_ALLOW_THREADS),
vagy a callback marshalolása arra a szálra, amelyik az interpreterhez
tartozik.
Korlát. A GIL és a PyGILState API
CPython-specifikus, és a GIL nélküli (free-threaded) mód viselkedése
verziófüggő; ezt az állítást a használt Python-kiadás dokumentációjával
kell ellenőrizni, és itt nem tekintjük igazoltnak.
A határfüggvényből C++ exception nem léphet ki: ha mégis, a
noexcept következtében a folyamat leáll, ami a leggyakoribb
„néha elszáll a szkriptem” hibaforrás. A minta: a wrapper belül
try/catch (...)-ben mindent elkap, és stabil
hibakódot ad vissza; a hibaüzenetet vagy a hívó által adott bufferbe
írja, vagy statikus, csak olvasható sztringként teszi elérhetővé. Utóbbi
élettartama a program teljes futása, ezért nem szabad felszabadítani, és
nem szabad hozzáírni.
A fogadó oldal a hibakódot azonnal alakítsa át saját kivételévé
(Pythonban raise), és ne engedje, hogy a natív kód hívjon
vissza a fogadó runtime-ba hibakezelés céljából. Ez a szabály
megfordíthatatlan irányt ad: a hibakonverzió mindig a fogadó oldalon
történik, a natív oldal csak adatot ad át.
Az erőforrás-felszabadítás ugyanígy szerződés. Pythonban a
__del__ nem megbízható időzítésű; helyette
contextlib.closing, weakref.finalize vagy egy
PyCapsule destruktor a javasolt minta. A natív oldalnak a
*_destroy függvényt idempotenssé vagy legalább nullázás
után hívhatóvá kell tennie, hogy a dupla felszabadítás ne legyen
azonnali UB.
A pybind11-jellegű könyvtárak a visszatérési érték tulajdonjogát külön policy-vel fejezik ki: másolás, mozgatás, tulajdon átvétele, hivatkozás, illetve a szülő objektumhoz kötött belső hivatkozás. A döntési tengely mindig ugyanaz: ki birtokolja a memóriát, és meddig él a hivatkozott objektum. A „hivatkozás a szülőre” típusú policy csak akkor helyes, ha a binding réteg garantáltan életben tartja a szülőt; enélkül a Python objektum egy már felszabadult C++ objektumra mutat, és a hiba a későbbi olvasásnál jelentkezik.
Korlát. A konkrét policy-nevek és pontos szemantikájuk verziófüggők. Ebben az egységben nem áll rendelkezésre ellenőrzött forrásjegyzet a pybind11 adott kiadásához, ezért a fenti leírás a döntési szempontokat rögzíti, nem a könyvtár dokumentált viselkedését; használat előtt a választott verzió hivatalos dokumentációjában kell ellenőrizni. Ugyanez vonatkozik a cffi és a ctypes konverziós részleteire: ezek CPython-verzióhoz kötött viselkedések.
dsprobe C ABI és ctypes kötésA példa egy kölcsönzött byte-tartományon dolgozó, callbacket is hívó natív könyvtár. A felület szándékosan csak C típusokat használ, és minden függvény hibakódot ad vissza.
/* dsprobe.h */
#ifndef DSPROBE_H
#define DSPROBE_H
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/* user -> a hívó kontextusa, a könyvtár nem értelmezi.
A data/len csak a hívás idejére kölcsönzött. */
typedef int (*dsprobe_visit_fn)(void* user, const uint8_t* data, size_t len);
int dsprobe_checksum(const uint8_t* data, size_t len, uint64_t* out);
int dsprobe_visit_tokens(const uint8_t* data, size_t len,
dsprobe_visit_fn fn, void* user);
/* A visszaadott pointer statikus, csak olvasható, nem szabad felszabadítani. */
const char* dsprobe_error_string(int code);
#ifdef __cplusplus
}
#endif
#endif// dsprobe.cpp — C++20, GCC/Clang, x86-64 System V ABI labor
#include "dsprobe.h"
namespace {
constexpr std::uint64_t kFnvOffset = 1469598103934665603ull;
constexpr std::uint64_t kFnvPrime = 1099511628211ull;
}
extern "C" int dsprobe_checksum(const std::uint8_t* data, std::size_t len,
std::uint64_t* out) noexcept {
if (out == nullptr) return -1;
if (data == nullptr && len != 0) return -1;
std::uint64_t h = kFnvOffset;
for (std::size_t i = 0; i < len; ++i) { h ^= data[i]; h *= kFnvPrime; }
*out = h; // teljes kimenet csak siker esetén íródik
return 0;
}
extern "C" int dsprobe_visit_tokens(const std::uint8_t* data, std::size_t len,
dsprobe_visit_fn fn, void* user) noexcept {
if (fn == nullptr) return -1;
if (data == nullptr && len != 0) return -1;
std::size_t start = 0;
while (start < len) {
while (start < len && data[start] == ' ') ++start;
std::size_t end = start;
while (end < len && data[end] != ' ') ++end;
if (end > start) {
const int rc = fn(user, data + start, end - start);
if (rc != 0) return rc; // a callback hibakódja változatlanul továbbmegy
}
start = end;
}
return 0;
}
extern "C" const char* dsprobe_error_string(int code) noexcept {
switch (code) {
case 0: return "ok";
case -1: return "invalid argument";
default: return "unknown error";
}
}g++ -std=c++20 -O2 -fPIC -shared -fvisibility=hidden \
-o libdsprobe.so dsprobe.cppA Python oldal a hosszt explicit módon adja át, a callbacket pedig pointer+hossz formában kapja meg, hogy a beágyazott NUL ne csonkítsa a szöveget:
import ctypes
from ctypes import (c_int, c_uint8, c_uint64, c_size_t, c_void_p,
POINTER, CFUNCTYPE)
lib = ctypes.CDLL("./libdsprobe.so")
lib.dsprobe_checksum.argtypes = [POINTER(c_uint8), c_size_t, POINTER(c_uint64)]
lib.dsprobe_checksum.restype = c_int
lib.dsprobe_visit_tokens.argtypes = [POINTER(c_uint8), c_size_t, c_void_p, c_void_p]
lib.dsprobe_visit_tokens.restype = c_int
payload = "héllo\x00világ".encode("utf-8") # 7 byte NUL-lal együtt
buf = (c_uint8 * len(payload)).from_buffer_copy(payload)
out = c_uint64(0)
rc = lib.dsprobe_checksum(buf, len(payload), ctypes.byref(out))
assert rc == 0, lib.dsprobe_error_string(rc)
VISIT = CFUNCTYPE(c_int, c_void_p, POINTER(c_uint8), c_size_t)
seen = []
@VISIT
def on_token(user, ptr, n): # n byte, nem strlen
seen.append(ctypes.string_at(ptr, n))
return 0
rc = lib.dsprobe_visit_tokens(buf, len(payload), on_token, None)
assert rc == 0
print(seen) # a NUL a token része maradHárom pont, amit érdemes észrevenni. A bytes objektum a
hívás idejére életben van, de a natív oldal a buf-ra nem
tart referenciát; a from_buffer_copy által létrehozott tömb
a Python oldal tulajdona. A VISIT és az
on_token a hívás teljes ideje alatt hatókörben van, tehát a
thunk nem szabadul fel — ezt a wrapper osztálynak kellene garantálnia,
ha a regisztráció hosszabb életű lenne. Végül a callback
POINTER(c_uint8) és c_size_t párost kap, nem
c_char_p-t: utóbbi a NUL-nál elvágna, és pont a beágyazott
NUL esetét rontaná el.
A leggyakoribb hiba a str átadása bytes
helyett: a kódolás ilyenkor nem az, amit a natív oldal vár. A második a
strlen-alapú natív API hívása hosszátadás nélkül, ami
beágyazott NUL-nál csonkít. A harmadik a callback objektum
élettartamának elszalasztása: a CFUNCTYPE példány lokálisan
keletkezik, majd a regisztráció után felszabadul, és a natív oldal
később dangling pointert hív. A negyedik a GIL elengedésének hiánya
hosszú C hívás körül, ami a fenti holtpont-vázlatot valósítja meg. Az
ötödik a size_t méretkonverzió ellenőrzés nélkül hagyása,
ami negatív vagy túl nagy értéknél csendes átforduláshoz vezethet.
Az FFI-határ szerződés, nem varázslat. A biztonságos minta négy
elemből áll: extern "C" wrapper csak C-kompatibilis
típusokkal; pointer+hossz alapú, nem tulajdonló bufferátadás dokumentált
élettartammal; hibakód- és release-szerződés, amelyben a konverzió
mindig a fogadó oldalon történik; valamint a callback élettartamának és
szálkontextusának explicit kezelése. A verziófüggő részleteket
(GIL-viselkedés, binding-policy-k, konverziós szabályok) minden esetben
a használt runtime és könyvtár dokumentációjából kell ellenőrizni.
1. feladat (tervezés). Adj egy
int dsprobe_count_byte(const uint8_t* data, size_t len, uint8_t what, size_t* out)
függvény C ABI-szerződését: mikor megengedett a NULL, mi
történik hibánál, mit ír ki a függvény, és hogyan biztosítod, hogy a
beágyazott NUL ne okozzon csonkolást? Írd le a Python oldali hívás
előfeltételeit is.
2. feladat (kódolás). Egészítsd ki a fenti
ctypes-kötést úgy, hogy a dsprobe_visit_tokens hibakódját a
fogadó oldal Python kivételévé alakítsa, és a hibaüzenetet a
dsprobe_error_string adja. A wrapper osztály tartsa életben
a callbacket, és legyen close() metódusa.
3. feladat (diagnosztika). Egy szolgáltatásban a
Python szál kihívja a natív run()-t, amely belül egy
munkaszálon meghívja a regisztrált callbacket. A folyamat holtpontba
kerül, majd egy külön futásban szegmentációs hibával elszáll. Add meg a
két hiba lehetséges okát, a diagnosztikai lépéseket és a javítást.
1. megoldás. A szerződés: data == NULL
csak len == 0 esetén megengedett; out == NULL
mindig hiba; a függvény a [data, data+len) tartomány minden
byte-ját vizsgálja, strlen-t nem használ, ezért a
0x00 közönséges értékként számolódik. Visszatérés:
0 siker, -1 érvénytelen argumentum;
*out-ba csak siker esetén ír. Python oldalon a hívás előtt
ellenőrizni kell, hogy 0 <= len <= SIZE_MAX, a buffer
pedig a hívás teljes ideje alatt él. Ellenőrzési feltétel: a
b"a\x00b" bemenetre az eredmény 3 hosszú bemenetet tükröz,
nem 1-et, és a data=NULL, len=5 hívás -1-et
ad.
2. megoldás. A wrapper osztály
__init__-ben mentse el a CFUNCTYPE példányt
attribútumba, és a natív regisztrációt csak utána végezze. A hívást
try/except fogja körbe; nem nulla
visszatérésnél RuntimeError-t kell dobni a
dsprobe_error_string(rc) szövegével. A close()
először a natív deregisztrációt hívja (ha van ilyen), majd törli a
callback-referenciát. Ellenőrzési feltétel: ha a callback
1-et ad vissza, a Python oldalon kivétel keletkezik, és a
natív oldalon nem szivárog erőforrás; ha a close() után
újra hívunk, az nem szegmentációs hiba, hanem dokumentált hiba.
3. megoldás. A holtpont valószínű oka: a Python szál
a GIL birtokában vár a run() visszatérésére, miközben a
munkaszál a callbackhez GIL-t kér — ez a fenti vázlat szerinti körkörös
várakozás. A javítás a GIL elengedése a blokkoló natív hívás körül, vagy
a callback marshalolása. A szegmentációs hiba valószínű oka: a
callbacket hordozó Python objektum felszabadult, miközben a natív oldal
még tárolja a függvénypointert. Diagnosztika: a callback regisztráció és
a hívás időpontjának naplózása, a wrapper élettartamának ellenőrzése,
valamint a natív oldal azon pontjainak átvizsgálása, ahol pointert
tárol. Ellenőrzési feltétel: a javított változatban a callback
objektum életben marad a deregisztrációig, és a blokkoló hívás alatt a
GIL elérhető más szálak számára.