Foundationপ্রথম নীতি থেকে
LEVEL 5লেসন ১৪/১৫অ্যাডভান্সড৫০ মিনিট

FFI ও ABI — ভিন্ন ভাষারা একে অপরকে ডাকে যেভাবে

FFI and ABI

দুই ভাষার function যেন একে অপরকে ডাকতে পারে — এই চুক্তির নাম ABI; চুক্তির একটা ধারাও ভাঙলে compiler চিৎকার করে না, ডেটা নীরবে নষ্ট হয়।

এই লেসন শেষে আপনি পারবেন

  • ABI-র inventory — calling convention, struct layout/padding, name mangling, register preservation, symbol visibility — প্রতিটা উপাদান চিনিয়ে বলতে পারবেন কোনটা ভাঙলে interop কোনভাবে ভাঙে
  • FFI-র তিন mechanism — runtime dlopen/dlsym + libffi, codegen-based bindings (SWIG/bindgen/cbindgen), আর C ABI as lingua franca — তুলনা করে কোন situation-এ কোনটা লিখতে পারবেন
  • C struct-এর sizeof আর offsetof padding rule মেনে হাতে হিসাব করে, ভুল layout-এর silent corruption চিনতে ও debug করতে পারবেন
  • GC-চালিত ভাষা থেকে C-কে pointer দেওয়ার তিন ownership protocol — borrowed, owned, hand-off — আর double-free/UAF ঠেকাতে handle+refcount wrapper লিখতে পারবেন
  • FFI call-এর marshalling খরচ মেপে বলতে পারবেন কখন boundary-crossing নগণ্য, কখন সম্পূর্ণ design-কে শাসন করে

আগে যা বোঝা থাকা দরকার

আগে এটা বুঝি

Python-এ তিন লাইনে SQLite database খুলেন:

import sqlite3
db = sqlite3.connect("app.db")

থামুন — sqlite3 module আসলে Python না। ভেতরে C-তে লেখা SQLite engine, সঙ্গে হাজার হাজার লাইনের parsing, B-tree, locking code। আপনার Python string ওই C জগতে ঢুকেছে, C-র memory-তে B-tree হাঁটেছে, ফল ফিরে এসে Python object হয়েছে — প্রতিটা query-তে, আপনার অজান্তে।

এই সেতুটা কীভাবে দাঁড়ায়? Assembly module-এর registers-and-calling-conventions লেসনে দেখেছেন — একই compiler-এর দুই C function-এর মধ্যে call-ও একটা কঠোর চুক্তি মেনে চলে: argument কোন register-এ, return কোথায়, কোন register caller বাঁচাবে। ভিন্ন compiler, ভিন্ন ভাষার মধ্যে সেই চুক্তিটাই ABI — Application Binary Interface — আর FFI (Foreign Function Interface) হলো ভাষার সেই সুবিধা যা আপনাকে চুক্তিটা সরাসরি লেখার দরজা খুলে দেয়।

কঠিন অংশটা এখানেই: চুক্তি ভাঙলে কেউ চিৎকার করে না। Type system নেই সেখানে, compiler warning নেই — শুধু ভুল offset-এ পড়া ডেটা, আর production-এ ছয় মাস পরে ফাটা bug। আজকের experiment-এ আমরা ইচ্ছা করে চুক্তি ভেঙে corruption-টা নিজের চোখে দেখব।

আজকের প্রশ্ন তিনটা: (১) দুই binary জগতের মধ্যে চুক্তিপত্রে ঠিক কী কী লেখা থাকে? (২) boundary পার হতে কী খরচ — আর সেই খরচ কখন গুরুত্বপূর্ণ? (৩) GC-চালিত জগৎ থেকে যে object C-কে ধার দিলেন, সেটা কে-ই বা ফেরত দেবে?

মূল ধারণা

ABI — চুক্তিপত্রের inventory

Source level-এ ভাষারা এক হলেও binary level-এ প্রতিটা function শুধু address, প্রতিটা ডেটা শুধু bytes। ওই bytes-গুলোর অর্থ ঠিক রাখতে ABI-তে যা যা লেখা থাকে:

উপাদানপ্রশ্নভাঙলে যা হয়
Calling conventionArgument কোন register/stack slot-এ? Return কোথায়?আবর্জনা argument, instant crash
Register preservationCaller-saved না callee-saved?ডাকার পরে loop variable রহস্যময় বদলে যায়
Type size ও alignmentlong কত byte? Struct-এর field কোন offset-এ?Field-গুলো সিলেন্টলি ভুল মান পড়ে
Struct-by-value returnRegister-এ (≤16B, SysV) না hidden pointer দিয়ে?Restype ভুল হলে RAX-এর garbage
Name manglingSymbol-এর নাম binary-তে ঠিক কী?undefined symbol link error
Symbol visibilityকোন symbol export হবে?dlsym NULL ফেরত

runtime-systems লেসনের startup-chain আর assembly module-এর stack-frame আলোচনা এই চুক্তির দুই প্রান্ত ছিল — process যে state-এ জন্মায়, frame যেভাবে সাজে। আজ সেই চুক্তিকে ভাষার মধ্যে টেনে আনছি।

Struct layout — সবচেয়ে ঘন ঘন ভাঙা ধারা

struct Sample {
    char  tag;      /* offset 0, জায়গা ১ বাইট */
                    /* ৩ বাইট padding — int-এর alignment ৪ */
    int   value;    /* offset 4 */
    char  flag;     /* offset 8 */
                    /* ৩ বাইট tail padding — struct-এর alignment ৪ */
};
/* sizeof(struct Sample) == 12 */

নিয়মটা দুই লাইনে: প্রতিটা field তার alignment-এর multiple offset-এ বসে; পুরো struct-ের size তার সর্বোচ্চ alignment-এর multiple। Field-এর ক্রম বদলালে size বদলায় — {int, char, char} হলে ৮, {char, int, char} হলে ১২। এই padding নিজে থেকেই প্রথম ফাঁদ: দুই ভাষা ভিন্ন ধারায় layout লিখলে byte-stream-টা এক থাকে কিন্তু অর্থ সরে যায়।

Name mangling — overloading-এর দাম

C++ overload দেয় — scale(double,double) আর scale(int,int) দুটোই আছে পারে। Binary-তে দুটোর নাম আলাদা হতে হবে, তাই compiler নামে signature গেঁথে দেয় (Itanium mangling):

C++ source:        double scale(double, double)
Binary symbol:     _Z5scaledd          ← name + param-types encoded

extern "C" double c_scale(double, double);
Binary symbol:     c_scale             ← mangling বন্ধ — C-র সাথে কথা বলা যায়

তাই FFI-র প্রথম নিয়ম: C++ library-কে ডাকতে হলে header-এ extern "C" — অথবা mangled নামটাই ডাকতে হবে। মনে রাখুন: extern "C" শুধু linkage বদলায়, C++ semantics (class, exception, template) যায় না — আর যাওয়াটাও দরকার, কারণ C জগতে তার কোনো ভাষাই নেই।

FFI-র তিন রাস্তা

১. Runtime loading + introspection
   dlopen("libfoo.so") → dlsym("foo") → signature হাতে ঘোষণা
   Python ctypes / CFFI, Java JNA — ভেতরে libffi যা runtime-এ
   calling convention execute করে (trampoline machine-code বানায়)

২. Codegen bindings (compile-time)
   SWIG / Rust bindgen / cbindgen / JNI headers — C header থেকে
   host-language declaration generate করে; type-check compile-এই

৩. C ABI as lingua franca
   সবাই C-র চুক্তিতে কথা বলে: Go(cgo), Rust(extern "C"), JVM(JNI),
   Python(refcount API), Node(NAPI) — এক ভাষা আরেক ভাষাকে সরাসরি না
   বুঝেও C-কে common tongue বানিয়ে কথা বলে

তৃতীয় পয়েন্টটাই industry-র আসল ঐকমত্য: কোনো ভাষা-জোড়ার জন্য dedicated bridge বানানো N² সমস্যা; সবাই C ABI-তে converge করলে সমস্যা N-এ নেমে আসে। এজন্যই Rust-এর #[no_mangle] pub extern "C", Go-র //export, Python-এর CPython API — সবার সবাই C-র ছদ্মবেশে।

ভেতরে কী ঘটছে

ctypes-এর একটা call-এর ভেতরটা

lib.add2(3, 4) লিখলে ঘটে:

Python → C একটা call-এর স্তর
  1. Your Pythonlib.add2(3, 4) — দেখতে ordinary call
  2. ctypes stubargtypes মেনে প্রতিটা argument C-representable করে marshal: int → c_long, str → UTF-8 bytes copy
  3. libffi trampolinesignature অনুযায়ী runtime-এ machine-code বসায় — registers ঠিক করে call instruction
  4. C ABI boundaryএখান থেকে সবাই সমান: SysV convention, callee চলে, GIL released (hand-written extension-এ explicit)
  5. Back againRAX → restype অনুযায়ী Python object বানিয়ে ফেরত — boxing খরচ এখানেই

দুইটা observation: প্রতিটা crossing-এ marshal-in + call + box-out — তিন খরচ; আর signature ভুল ঘোষণা করলে (restype/argtypes) কোনো স্তরই আটকাবে না — machine সত্যিই চুক্তির মতোই চলবে, ভুল চুক্তিতেও।

Marshalling-এর আসল বিল

Boundary পার হওয়া ফ্রি না, আর খরচগুলো asymmetric:

যা পার হয়খরচলুকানো বিষয়
int/float scalarপ্রায় শূন্য (register-এই)তবু Python int → C long-এ unbox লাগে
stringUTF-8 encode + copy + NUL terminateবড় string = বড় memcpy, প্রতি call-এ
array/buffercopy, বা zero-copy view (ঝুঁকিপূর্ণ)C-র হাতে থাকতে Python side mutate করলে?
struct by valueSysV classification: ≤16B register pair, বড়টা memoryrestype এক অক্ষর ভুল = RAX garbage
object pointerসরাসরি দেওয়া যায় নাGC জগতের object non-GC জগতে রাখা — পরের অংশ

আর একটা লুকানো খরচ: GIL। Hand-written extension Py_BEGIN_ALLOW_THREADS না দিলে C কোড চলার পুরো সময় Python-এর অন্য সব thread আটকে থাকে — ctypes call-এ এটা automatic, কিন্তু আপনি জানা দরকার কারণ concurrency-models লেসনের event-loop blocking-সমস্যার এটাই Python রূপ।

GC interplay — কার হাতে object, কে ফেরত দেবে

garbage-collection আর reference-counting লেসনে দেখেছেন: collector বাঁচায় যা-কে reach করা যায়। C-র ভেতরে থাকা raw pointer collector-এর নজরের বাইরে — CPython-এর refcount দেখে, JVM-এর tracing দেখে না। তাহলে তিনটা protocol ছাড়া উপায় নেই:

borrowed : C শুধু call-এর সময় ব্যবহার করে, সংরক্ষণ করে না — সবচেয়ে নিরাপদ
owned    : C-র হাতে মালিকানা — সে-ই free করবে; Python আর touch করবে না
hand-off : মালিকানা দুইদিকে চলাচল করে — তখন explicit retain/release API
           লাগে, আর wrapper-কে __del__/finalizer-এ release বসাতে হয়

Moving collector-ওয়ালা ভাষায় (JVM, .NET) আরও এক স্তর: GC object সরাতে পারে, তাই C-কে raw address দেওয়াই অবৈধ — pinned handle বা copy বাধ্যতামূলক (JNI-র GetByteArrayElements ঠিক এটাই করে)। আজকের build-এ hand-off protocol- টা হাতে-কলমে লিখবেন।

উদাহরণ

Padding walkthrough — compiler-এর চোখে

/* layout.c — gcc layout.c && ./a.out */
#include <stdio.h>
#include <stddef.h>

struct A { char t; int v; char f; };   /* খারাপ ক্রম */
struct B { int v; char t; char f; };   /* ভালো ক্রম — একই fields */

int main(void) {
    printf("A: size=%zu  tag@%zu value@%zu flag@%zu\n",
           sizeof(struct A),
           offsetof(struct A, t), offsetof(struct A, v), offsetof(struct A, f));
    printf("B: size=%zu  value@%zu tag@%zu flag@%zu\n",
           sizeof(struct B),
           offsetof(struct B, v), offsetof(struct B, t), offsetof(struct B, f));
    return 0;
}
A: size=12   tag@0  value@4  flag@8     ← ৬ বাইট ডেটা, ৬ বাইট ফাঁকা!
B: size=8    value@0 tag@4  flag@5     ← একই ডেটা, ৩৩% ছোট

FFI-র দৃষ্টিতে এখানেই প্রথম বিপদ: Python-এর ctypes struct যদি A-র জায়গায় B-র ক্রম ঘোষণা করে, প্রতিটা field silently ভুল byte পড়বে — কোনো error নেই, শুধু ভুল সংখ্যা। দ্বিতীয় বিপদ ভিন্ন platform: long Windows-এ ৪, Linux-এ ৮ বাইট — একই header, ভিন্ন layout।

ভুল চুক্তির ফল — একটা সংখ্যা-trace

C জানে struct A (tag@0, value@4), Python ভেবেছে B (value@0):

Python লিখেছে (B-layout মনে করে):
  offset 0: E8 03 00 00     ← value = 1000
  offset 4: 41              ← tag = 'A'

C পড়ছে (A-layout জেনে):
  tag@0   = 0xE8            = 232        ← value-র নিচের byte!
  value@4 = 0x00000041      = 65         ← 'A'-ই, int ভেবে
  flag@8  = ???             = garbage    ← object-এর বাইরেই পড়েছে

ফল: কোনো error নেই। শুধু প্রতিটা মান ভুল, একটা read out-of-bounds।

এই নীরবতাই FFI debugging-কে কঠিন করে — আর আজকের experiment-এ এটা reproduce করবেন।

ছবিতে

Boundary-র মানচিত্র

┌─────────────────────────────┐      ┌─────────────────────────────┐
│ PYTHON জগৎ                  │      │ C জগৎ                       │
│                             │      │                             │
│  objects, GC heap, GIL,     │      │  raw memory, manual lifetime│
│  dynamic types              │      │  fixed layouts              │
│                             │      │                             │
│   lib.add2(3, 4) ───────────┼───►  │  long add2(long a, long b)  │
│        ▲ marshal-in         │ ABI  │        │                    │
│        ▼ box-out            │◄──── │        ▼                    │
│   int result ◄──────────────┼────  │       RAX                   │
│                             │      │                             │
└─────────────────────────────┘      └─────────────────────────────┘
                 মাঝখানে একটাই common ভাষা: SysV x86-64 ABI
একই আট বাইট, দুই অর্থ:

PYTHON-এর চোখে:               C-এর চোখে:
┌─────────────────┐           ┌─────────────────┐
│ PyObject*:      │           │ char buf[8]:    │
│  refcnt=1       │  ──same──►│  যা খুশি তা-ই    │
│  type=str       │  bytes    │  যতক্ষণ খুশি     │
│  "hello"        │           │                 │
└─────────────────┘           └─────────────────┘
GC এটাকে সরাতে/মুছতে পারে        C জানেই না কে মালিক
চুক্তির দুই মুখ — বাঁদিকে ভাষা যা দেখে (typed call), ডানদিকে silicon যা দেখে (register + bytes)। FFI-র সমস্ত বিপদ এই দুই দৃষ্টিভঙ্গির মিলনবিন্দুতে: যা এক পাশে 'object', অন্য পাশে মাত্র address।

নিজে চালিয়ে দেখুন

EXPERIMENT

চুক্তি মানুন, তারপর ভেঙে ফেলুন — ctypes forensics

Linux/macOS, gcc + Python 3· ২৫ মিনিট

ধাপ ১ — C লাইব্রেরি:

/* libtiny.c */
#include <stdlib.h>
#include <string.h>

long add2(long a, long b) {
    return a + b;
}

typedef struct {
    char tag;        /* @0  */
                     /* pad 3 */
    int  value;      /* @4  */
    char flag;       /* @8  */
                     /* pad 3 — size 12 */
} Point;

Point make_point(char tag, int value, char flag) {
    Point p;
    p.tag = tag;
    p.value = value;
    p.flag = flag;
    return p;
}

int sum_point(const Point *p) {
    return p->value + p->tag + p->flag;
}
gcc -shared -fPIC -o libtiny.so libtiny.c
nm -D libtiny.so | grep -E " T (add2|make_point|sum_point)"
# দেখবেন: তিনটাই T (exported text symbol) — dlopen/dlsym-এর দরজা খোলা

ধাপ ২ — সঠিক wrapper:

# tiny_ok.py
import ctypes

lib = ctypes.CDLL("./libtiny.so")

class Point(ctypes.Structure):
    _fields_ = [("tag", ctypes.c_char),     # C-র layout-এর হুবহু প্রতিলিপি
                ("value", ctypes.c_int),
                ("flag", ctypes.c_char)]

lib.add2.argtypes = [ctypes.c_long, ctypes.c_long]
lib.add2.restype = ctypes.c_long
lib.make_point.argtypes = [ctypes.c_char, ctypes.c_int, ctypes.c_char]
lib.make_point.restype = Point                          # struct by value!
lib.sum_point.argtypes = [ctypes.POINTER(Point)]
lib.sum_point.restype = ctypes.c_int

print("add2:", lib.add2(20, 22))                        # 42

p = lib.make_point(b"A", 1000, b"B")
print("fields:", p.tag, p.value, p.flag)                # b'A' 1000 b'B'
print("sizeof match:", ctypes.sizeof(Point))            # 12 — C-র সাথে মিল
print("sum:", lib.sum_point(ctypes.byref(p)))           # 1000+65+66=1131

ধাপ ৩ — চুক্তি ভাঙুন (ইচ্ছা করে):

# tiny_broken.py — একই library, ভুল layout ঘোষণা
import ctypes

lib = ctypes.CDLL("./libtiny.so")

class WrongOrder(ctypes.Structure):
    _fields_ = [("value", ctypes.c_int),    # C জানে ওখানে tag!
                ("tag", ctypes.c_char),
                ("flag", ctypes.c_char)]
    # size 8 — C-র Point size 12: চুক্তি দুই জায়গায় ভাঙা

w = WrongOrder(value=1000, tag=b"A", flag=b"B")
lib.sum_point.argtypes = [ctypes.POINTER(WrongOrder)]   # ctypes থামাবে না!
print("sizeof:", ctypes.sizeof(w))                      # 8 ≠ 12
print("sum:", lib.sum_point(ctypes.byref(w)))
# C পড়ছে: tag@0=0xE8(232), value@4=('A'..'B' bytes)=65-ish, flag@8=OOB garbage
# ফল: কোনো exception নেই — প্রতিবার ভিন্ন ভুল সংখ্যা। এটাই silent corruption।
python3 tiny_ok.py
python3 tiny_broken.py     # আবার চালান — একই ভুল সংখ্যা, প্রতিবার

tiny_broken-এর sum এই machine-এ 16937 — deterministic কিন্তু সম্পূর্ণ অর্থহীন: C-র চোখে tag@0 = 0xE8 signed char = −24, value@4 = 'A','B' bytes মিলে 16961, flag@8 object-এর বাইরে থেকে 0। কোনো exception নেই, কোনো warning নেই — শুধু অর্থহীন সংখ্যা। ভিন্ন platform/build-এ সংখ্যাটা ভিন্ন হবে; অর্থহীনতা থাকবে একই।

ধাপ ৪ — crossing-এর দাম:

# cost.py
import ctypes
import time

lib = ctypes.CDLL("./libtiny.so")
lib.add2.argtypes = [ctypes.c_long, ctypes.c_long]
lib.add2.restype = ctypes.c_long

def bench(label, fn, n=5_000_000):
    acc = 0
    t0 = time.perf_counter()
    for i in range(n):
        acc = fn(acc, i)
    dt = time.perf_counter() - t0
    print(f"{label:<14} {dt/n*1e9:6.0f} ns/call  (acc={acc})")

bench("ctypes add2", lambda a, i: lib.add2(a, 1))
bench("pure python", lambda a, i: a + 1)

Typical (এই লেখকের মেশিনে; আপনারটায় ভিন্ন হবে, অনুপাত থাকবে):

ctypes add2       770 ns/call
pure python       108 ns/call

যা প্রমাণিত: সঠিক চুক্তিতে সেতু স্বচ্ছ; ভুল চুক্তিতে কোনো স্তরই বাধা দেয় না — এমনকি object-এর বাইরে পড়েও; আর ছোট কাজে crossing-নিজেই দ্বিগুণ খরচ — FFI-র golden rule: boundary কমাও, এক call-এ বেশি কাজ পাঠাও (batch API), প্রতি element-এ এক call না।

এটা কী প্রমাণ করে

সঠিক argtypes/restype-এ ctypes-C সেতু নিখুঁত কাজ করে; layout একটু সরালেই কোনো error ছাড়াই ভুল মান আর out-of-bounds read হয়; nm -D-তে export-এর তালিকা দৃশ্যমান; আর ছোট call-এ crossing-cost নিজেই মূল খরচ হয়ে দাঁড়ায়।

নিজে বানান

BUILD IT

Refcounted handle wrapper — ownership handoff-এর পূর্ণ চুক্তি

C + Python ctypes · ●●●○○
  1. C পাশে handle_create/retain/release API লিখুন — refcount + stderr log, জীবন-মৃত্যুর হিসাব দৃশ্যমান
  2. ctypes Structure দিয়ে C layout-এর প্রতিলিপি বানান, restype/argtypes সব ঘোষণা করুন
  3. Python wrapper class — single owner, __del__-ই release-এর একমাত্র দরজা
  4. share() দিয়ে hand-off protocol দেখান — retain ছাড়া share মানে future double-free
  5. del করে প্রমাণ করুন: refs 1→0 হলে C-side free, আর কোনো leak বা double-free নেই

লক্ষ্য: hood-এর ownership matrix-এর তৃতীয় সারি (hand-off) পুরোপুরি বাস্তবে — দুই জগতের কেউ কারো মেমরি না চুরি করে একই object share করবে।

/* libhandle.c — refcounted handle-এর C পাশ */
/* compile: gcc -shared -fPIC -o libhandle.so libhandle.c */

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef struct {
    int  id;
    int  refs;
    char tag[24];
} Handle;

static int next_id = 1;

Handle *handle_new(const char *tag) {
    Handle *h = malloc(sizeof(Handle));
    h->id = next_id++;
    h->refs = 1;                      /* caller-এর হাতে ঠিক ১টা reference */
    snprintf(h->tag, sizeof(h->tag), "%s", tag);
    fprintf(stderr, "[C] new     id=%d refs=%d (%s)\n", h->id, h->refs, h->tag);
    return h;
}

void handle_retain(Handle *h) {
    h->refs++;
    fprintf(stderr, "[C] retain  id=%d refs=%d\n", h->id, h->refs);
}

void handle_release(Handle *h) {
    h->refs--;
    fprintf(stderr, "[C] release id=%d refs=%d\n", h->id, h->refs);
    if (h->refs == 0) {
        fprintf(stderr, "[C] FREE    id=%d — হিসাব শেষ\n", h->id);
        free(h);
    }
}

int handle_id(const Handle *h) { return h->id; }
"""handle.py — ownership handoff মেনে চলা ctypes wrapper"""
import ctypes
import os

_lib = ctypes.CDLL(os.path.join(os.path.dirname(os.path.abspath(__file__)),
                                "libhandle.so"))


class CHandle(ctypes.Structure):
    _fields_ = [("id", ctypes.c_int),
                ("refs", ctypes.c_int),
                ("tag", ctypes.c_char * 24)]


_lib.handle_new.restype = ctypes.POINTER(CHandle)
_lib.handle_new.argtypes = [ctypes.c_char_p]
_lib.handle_retain.argtypes = [ctypes.POINTER(CHandle)]
_lib.handle_release.argtypes = [ctypes.POINTER(CHandle)]
_lib.handle_id.argtypes = [ctypes.POINTER(CHandle)]
_lib.handle_id.restype = ctypes.c_int


class Handle:
    """Python-পাশের owner: release-এর একমাত্র দরজা __del__"""

    def __init__(self, tag):
        self._ptr = _lib.handle_new(tag.encode())

    def share(self):
        """hand-off: নতুন owner — তাই retain আবশ্যক, নইলে ভবিষ্যতে
        দুইজন release করবে = double-free"""
        _lib.handle_retain(self._ptr)
        twin = Handle.__new__(Handle)
        twin._ptr = self._ptr
        return twin

    @property
    def id(self):
        return _lib.handle_id(self._ptr)

    def __del__(self):
        ptr = getattr(self, "_ptr", None)
        if ptr is not None:
            _lib.handle_release(ptr)
            self._ptr = None        # resurrection-এর বিরুদ্ধে বীমা


if __name__ == "__main__":
    a = Handle("database")            # refs 1
    b = a.share()                     # refs 2 — এখন দুই owner
    print("ids:", a.id, b.id)
    del b                             # refs 2 → 1 — a এখনো বাঁচিয়ে আছে
    print("a alive after del b:", a.id)
    del a                             # refs 1 → 0 — C-side FREE log দেখুন
    print("end — কোনো leak না, কোনো double-free না")
gcc -shared -fPIC -o libhandle.so libhandle.c
python3 handle.py

Output (pipe-এ: stderr unbuffered, stdout block-buffered — তাই [C] log আগে; terminal-এ interleave হবে):

[C] new     id=1 refs=1 (database)
[C] retain  id=1 refs=2
[C] release id=1 refs=1
[C] release id=1 refs=0
[C] FREE    id=1 — হিসাব শেষ
ids: 1 1
a alive after del b: 1
end — কোনো leak না, কোনো double-free না

শেষের release refs=0 + FREE টা interpreter shutdown-এ a-র __del__ থেকে — refcount-এর deterministic finalization, GC লেসনের স্মৃতি। দুইবার release হয়নি, leak হয়নি — protocol কাজ করেছে।

নিজে বাড়ান:

  1. share()-এ retain লাইনটা মুছে দিন আর দুই del করুন — double-free-এর crash/glibc abort message-টা পড়ুন। এটাই protocol-ভাঙার দাম।
  2. Context manager (__enter__/__exit__) যোগ করুন — scope-borrowed pattern-এর Python রূপ।
  3. Weakref registry যোগ করুন: একই C pointer-এ দ্বিতীয় Python wrapper বানানো ঠেকান (identity map) — নইলে দুই wrapper-ই __del__-এ release করবে।
  4. valgrind (valgrind --leak-check=full python3 handle.py) চালিয়ে প্রমাণ করুন zero leaks — তারপর একটা release comment করে আবার চালিয়ে leak report পড়ুন।

বাস্তব সিস্টেমে

FFI/ABI যেখানে প্রতিদিন কাজ করে

NumPy, PyTorch, pandas। Python-এর numeric ecosystem-এর গতির রহস্য FFI design: এক boundary crossing-এ পুরো array-র operation (vectorized kernel C/C++/CUDA-তে), element-প্রতি crossing নয়। আজকের cost measurement-এর golden-rule-টাই এদের architecture।

SQLite। সম্ভবত পৃথিবীর সবচেয়ে বেশি deployed C library — প্রতিটা phone, browser, OS-এ। এর C API-টাই এত শুদ্ধ যে প্রায় প্রতিটা ভাষা সরাসরি bind করেছে — C-as-lingua-franca-র শ্রেষ্ঠ উদাহরণ।

Android JNI। Java/Kotlin app-এর প্রতিটা native feature JNI-র মধ্য দিয়ে — reference management (LocalRef/GlobalRef!), pinned arrays, thread attach — আজকের ownership protocol-গুলোর industrial-scale রূপ।

Go আর cgo। Go-র documentation সরাসরি সতর্ক করে: cgo call দামি (scheduler hand-off + stack switch), goroutine-দের মধ্যে C callback বিপজ্জনক — concurrency-models লেসনের M:N model-এর সীমানা এখানেই।

WASM। WebAssembly-র উচ্চাকাঙ্ক্ষা: C ABI-র বাইরে একটা portable ABI — ভিন্ন ভাষা ভিন্ন runtime নিয়েও এক sandbox-এ কথা বলবে। WASI আর component model এই চেষ্টারই নাম।

Linux kernel-এর প্রতিশ্রুতি। “We do not break userspace” — kernel-এর syscall ABI freeze মানেই ১৯৯১-এর binary আজও চলে। ABI stability-র সবচেয়ে সফল রাজনীতি; virtual-memory/system-call লেসনের জগতটা এই প্রতিশ্রুতির উপরেই দাঁড়িয়ে।

যে ভুলগুলো সবাই করে

“সব ভাষা শেষপর্যন্ত machine code-এ যায়, তাই সরাসরি একে অপরকে ডাকা উচিত।”

Machine code শুধু instruction sequence — চুক্তি না। Call সফল হতে হলে argument-এর register, struct-এর offset, symbol-এর নাম, lifetime-এর নিয়ম — সব এক হতে হবে; এটাই ABI, আর এটা স্বয়ংক্রিয়ভাবে মেলে না। C++ আর Rust-এর মতো দুই modern ভাষাও পরস্পরকে সরাসরি ডাকতে পারে না — দুজনেই C ABI-তে নেমে কথা বয়।

“FFI call প্রায় ফ্রি — তো একটা function call-ই তো।”

আজকের measurement: ছোট কাজে crossing নিজেই মূল খরচ (marshal + box + কখনো GIL dance)। ভারী কাজে (১০০ms image op) crossing negligible — তাই rule-টা relative: crossing সংখ্যা কমাও, প্রতি crossing-এর কাজ বাড়াও। Element- প্রতি FFI call লেখা numeric কোড সহজেই pure-Python-এর চেয়ে ধীর হয়ে যায় — ironic কিন্তু সাধারণ।

“Struct layout universal — একই C source সব জায়গায় একই bytes।”

Layout compiler, platform আর flags-এর ফাংশন: long Windows-LLP64-তে ৪ বাইট, Linux-LP64-তে ৮; #pragma pack/__attribute__((packed)) alignment বদলায়; field-order-ই size বদলায় (আজকের example)। Cross-platform FFI-তে layout assumption-এর বদলে generator (bindgen) বা explicit fixed-width types (int32_t) + static_assert(sizeof) ব্যবহার করুন।

“`extern "C"` বসালেই C++ object C-কে দেওয়া যাবে।”

extern "C" শুধু name mangling বন্ধ করে — linkage চুক্তি। Class instance pass করা মানে vtable pointer, exception machinery, constructor invariant — C-র জগতে যার কোনো অর্থ নেই। যা দেওয়া যায়: POD data, opaque pointer (handle), আর flat functions। এজন্যই প্রতিটা mature C++ library-র C API layer আলাদা থাকে — opaque struct pointer + create/destroy/free function trio।

বুঝেছেন কি না দেখুন

1

struct S { char a; double b; short c; }; — x86-64-তে sizeof আর প্রতিটা field-এর offset হিসাব করুন। Field-ক্রম বদলে double, short, char করলে size কত?

প্রয়োগ

প্রথম ক্রমে: a@0 (১ বাইট), b-র alignment ৮ → padding ৭ → b@8, c@16 (২ বাইট), tail padding ৬ → sizeof = 24। দ্বিতীয় ক্রমে: b@0, c@8, a@10, tail padding ৫ → sizeof = 16। একই fields, ৮ বাইট পার্থক্য — আর FFI-তে এই offset-গুলোই চুক্তি। Alignment-এর সর্বোচ্চ member = ৮, তাই size সবসময় ৮-এর multiple হতে বাধ্য।

2

C++-এ function overloading সম্ভব, C-তে না। Name mangling এই পার্থক্যটা binary level-এ কীভাবে solve করে, আর extern "C" কেন FFI-র প্রবেশদ্বার?

যুক্তি

Binary-তে symbol = নাম, তাই overload-দুই function-কে ভিন্ন symbol দরকার। Mangler signature encode করে নামে (scale(double,double) → _Z5scaledd), ফলে দুই overload দুই distinct symbol। C-তে overloading নেই — নামই যথেষ্ট, mangling নেই। extern "C" সেই unmangled চুক্তিতে ফেরত দেয়, ফলে C linker, dlsym, ctypes — সবাই symbol খুঁজে পায়। মনে রাখতে হবে এটা শুধু naming চুক্তি: C++ semantics বাইরে থেকে ডাকা বেআইনিই থাকে।

3

ctypes wrapper-এ কেউ লিখল: lib.make_point.restype = ctypes.c_void_p (সঠিক: Point)। কী ঘটবে, আর কোন লক্ষণে বুঝবেন ভুলটা?

প্রয়োগ

SysV ABI-তে ১২-বাইট struct memory-তে ফেরত আসে (hidden destination pointer) — RAX-এ pointer-টা থাকে, কিন্তু c_void_p ঘোষণায় ctypes RAX-কেই ফল ভাববে। সেটা আসলে destination-buffer-এর address বা তার অংশ — একটা অস্থায়ী stack address, যা call শেষে অবৈধ। লক্ষণ: ফেরত “pointer”-টা কখনো valid দেখায় (dangling), dereference-এ রহস্যজনক garbage বা crash, আর প্রতি run-এ ভিন্ন মান। শিক্ষা: restype শুধু convenience না — struct-by-value return-এর calling-convention branch এটাই নির্ধারণ করে।

4

আপনার C library তৈরি করবে এমন session object, যা Python থেকে দীর্ঘদিন ধরে রাখা হবে, মাঝে মাঝে একাধিক Python object একসাথে ব্যবহার করবে। Ownership protocol design করুন — কে কখন free করবে, race/leak/double-free কোথায় আটকাবেন?

ডিজাইন

Hand-off + refcount protocol: (১) session_create → refs=1, Python wrapper single-owner; (২) প্রতি নতুন holder-এ session_retain, holder মরলে (wrapper-এর __del__) session_release; refs=0-তেই C-side free — মালিকানার হিসাব এক জায়গায়, atomic counter দিলে multi-thread-safe। Leak-আটকাতে: weakref identity map — এক pointer-এ এক wrapper, duplicate __del__ অসম্ভব। Race-আটকাতে: release-এর আগে ptr None-set (resurrection guard), C-side counter atomic। Double-free structurally অসম্ভব হয় কারণ wrapper-এর release-দরজা একটাই আর identity map নকল wrapper আটকায়। Bonus: context manager দিয়ে scoped borrow API — long-lived hold আর transient use আলাদা করে।

5

Linux kernel প্রতি release-এ ভেতরে বদলায় — scheduler, allocator, page table layout। তবু ১৯৯০-এর compiled binary আজও চলে। কোন সীমানার কোন স্থায়িত্ব এটা সম্ভব করল, আর কোন সীমানা ইচ্ছামতো বদলানো যায়?

যুক্তি

স্থির: syscall ABI — syscall number, argument register, errno semantics, procfs format, ELF/process-startup contract। Kernel-user boundary-টা distribution/vendor/app — সবার common floor, ভাঙলে পুরো ecosystem ভাঙে (“we do not break userspace”)। বদলানো যায়: kernel-এর সব internal interface — scheduler algorithm, memory allocator, driver model, file-system internals — কারণ ওদের client শুধু kernel নিজে, একই release-এ recompile হয়। শিক্ষাটা FFI-design-এও এক: stable contract রাখুন শুধু সবচেয়ে বাইরের সীমানায়, ভেতরটা মুক্ত রাখুন — আর stable surface যত ছোট, maintenance তত সস্তা (symbol visibility নীতির সাথে একই সূত্র)।

এরপর কী

এরপর কী

এই module-এর একেকটা লেসন ভাষার একেকটা যন্ত্র খুলে দেখিয়েছে — আর আজ দেখলাম ভাষারাও পরস্পরের সাথে যন্ত্রের ভাষায়ই কথা বলে: ABI-র নীরব চুক্তিতে। এবার module-এর শেষ লেসনে ফেরা যাক সবচেয়ে বড় প্রশ্নে, যেটার উত্তর কোনো যন্ত্র না — চিন্তার ধরন: imperative, functional, OO, logic — paradigm আসলে কী দেয়? কোন restriction কোন guarantee কেনে? একটাই সমস্যা চার ফ্রেমে সমাধান করে দেখব আর জিজ্ঞেস করব: কোন ফ্রেমে bug করা সবচেয়ে কঠিন?

পরের লেসন: Paradigm — imperative, functional, logic, OO: চিন্তার চার ফ্রেম।

আরও পড়ুন

  • System V Application Binary Interface — x86-64 supplement — Michael Matz, Jan Hubička, Andreas Jaeger, Mark Mitchell · Calling convention, struct classification (INTEGER/SSE), register preservation-এর primary spec
  • Linkers and Loaders — John R. Levine · Symbol export/import, visibility আর dynamic linking-এর ভিত্তি — PIC লেসনের ধারাবাহিকতা
  • A Portable Foreign Function Interface Library (libffi) — Anthony Green · Runtime-এ calling convention implement করার যন্ত্র — ctypes-এর ইঞ্জিন
  • Python ctypes documentation — Python Software Foundation · restype/argtypes contract আর struct mapping-এর reference