Zack Li logoZack Li

Back to Blog

How binding works with C/C++: ABI, FFI and vtable

Created on July 15, 2026

This session explains ABI and FFI: how a foreign runtime binds to a C/C++ DSO under the platform C ABI. It also introduces extern "C" linkage, opaque handles, C function tables, and C++ vtables.

1. API vs ABI

  • API is the source-code contract (headers).
  • ABI is the binary contract between already-compiled components.

An ABI mismatch can link successfully and still crash at runtime.

source  ──API──►  header
binary  ──ABI──►  DSO   (.so / .dylib / .dll)

Why C ABI is the common native boundary

Both C and C++ define ABIs. C's is the platform contract. C++'s depends on the compiler and the standard library.

  • C — name, calling convention, C types, layout, ownership. Symbol: foo_run. No hidden state. Any runtime that knows the platform C ABI can call it.
  • C++ — plus this, vtable, RTTI, exceptions, ctor/dtor. Symbol: _Z3fooi (mangled). std::string differs by libstdc++ / libc++ / MSVC. Only a matching C++ toolchain can call it.

C is the common FFI target: a small, explicit binary contract every runtime already knows how to call.

Python / Swift / Rust / Go / Java / C++  ──►  platform C ABI  ──►  native library

2. C ABI, extern "C", FFI, and vtable

These layers answer different parts of “how does another language bind to a C/C++ DSO?”

TermDescription
C ABI (Application Binary Interface)Binary contract between already-compiled components.
extern "C"C++ only. Export foo, not _Z3fooi, so the loader can find the name.
FFI (Foreign Function Interface)How this language describes name / args / return and issues the call.
Opaque handleCaller sees Foo*, not struct Foo, so the private layout can change without breaking the ABI.
Symbol tableName → address in the DSO.
C function tableA struct of function pointers you define, so the caller dispatches run / reset / destroy without exporting every symbol.
C++ vtable (virtual function table)The compiler's table for virtual calls (object → override).

extern "C"

C++ only: export foo, not _Z3fooi, so the loader can find the name. C already has C linkage. Shared headers wrap the declaration in #ifdef __cplusplus.

int foo(int);                         // C++ linkage → typically _Z3fooi
extern "C" int foo(int);              // C linkage   → typically foo
extern "C" std::string foo();         // C name, C++ type — not portable

FFI

FFI is how this language describes a native function (name, args, return) and issues the call. It is not the ABI; it uses the C ABI.

Python → ctypes / cffi     Java → JNI     Swift → Clang importer     Rust → extern "C"

Python cannot execute int foo_run(Foo*, int32_t) itself. ctypes tells the runtime the contract; the CPU then follows the platform C ABI. The loader uses the symbol table to find foo_run.

lib = ctypes.CDLL("./libfoo.so")              # map DSO; resolve symbols
lib.foo_run.argtypes = [ctypes.c_void_p, ctypes.c_int32]  # Foo*, int32_t
lib.foo_run.restype = ctypes.c_int
lib.foo_run(foo, 42)                          # FFI → C ABI → foo_run
Python → ctypes (FFI) → platform C ABI → symbol table → foo_run
         or backend_get_api() → C function table → private C++

pybind11 is convenient Python → C++; it ships a CPython extension, not a language-neutral C ABI.

Opaque handle

The caller sees Foo*, not struct Foo. Pass it through create / run / destroy. You cannot write foo->state or sizeof(Foo). The private struct in .cpp can change without breaking the ABI.

// foo.h
#ifdef __cplusplus
extern "C" {
#endif
typedef struct Foo Foo;           // incomplete: no fields, no sizeof
Foo* foo_create(void);
int  foo_run(Foo*, int32_t);
void foo_destroy(Foo*);
#ifdef __cplusplus
}
#endif
// foo.cpp — layout + new/delete stay in this DSO
#include "foo.h"
struct Foo { int state; };
Foo* foo_create(void) { return new Foo{0}; }
int  foo_run(Foo* foo, int32_t v) { return foo->state + v; }
void foo_destroy(Foo* foo) { delete foo; }

Symbol table

The symbol table maps a name to an address in the DSO. The linker writes it; the loader / dlsym / ctypes reads it. Inspect with nm (T = exported code, U = imported). GDB (info address foo_run) looks up at runtime; it does not export symbols.

$ nm -D libfoo.so
0000000000001129 T foo_create     # exported
0000000000001134 T foo_run
0000000000001145 T foo_destroy
                 U __cxa_atexit   # imported

C function table

C has no classes, so you publish the method list yourself: a struct of function pointers (run, reset, destroy) whose layout you version. Export one getter; nm shows that name, not the implementations behind it. The caller writes api->run(...), and any C++ object stays hidden in context. Add new slots at the end — do not reorder them or change their signatures.

typedef struct {
    void* context;                    // private object; caller never inspects it
    int  (*run)(void*, int);
    void (*destroy)(void*);
} BackendApi;

BackendApi* backend_get_api(void);    // only this name is in the symbol table
api->run(api->context, 42);           // load pointer, then call

C++ vtable

This is the compiler's table for virtual calls. You write backend->run(); the compiler does object → hidden vptr → slot. You do not control the layout (C++ ABI), so do not expose it as a public C API — plugins use a C function table instead. GDB can follow an object's vptr at runtime; that is not the DSO symbol table.

class Backend {
public:
    virtual int run() = 0;
    virtual void reset() = 0;
};

Backend* b = new GpuBackend();
b->run();                                   // object → vptr → vtable[run] → GpuBackend::run
GpuBackend object
┌──────────────┐
│ vptr ──────────────┐
│ fields       │     ▼
└──────────────┘  vtable for GpuBackend
                  │ run()    → GpuBackend::run
                  │ reset()  → GpuBackend::reset
                  │ dtor …
nm -C libfoo.so | grep vtable    # may list "vtable for GpuBackend"

© Copyright 2026 Zack Li.