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::stringdiffers 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?”
| Term | Description |
|---|---|
| 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 handle | Caller sees Foo*, not struct Foo, so the private layout can change without breaking the ABI. |
| Symbol table | Name → address in the DSO. |
| C function table | A 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"