cffi
Erlang C Foreign Function Interface — call C library functions from Erlang at runtime, without writing C bindings or NIFs by hand.
Inspired by Common Lisp's CFFI and Python's ctypes/cffi.
Features
- ABI mode (NIF) — zero-copy calls via libffi in a NIF; fastest path
- Port safe mode — C code runs in a separate OS process; a crash cannot kill the BEAM VM
- Rich type system — structs, unions, enums, typedefs, arrays, nested types
- C→Erlang callbacks — pass Erlang closures as C function pointers
(
qsort, event hooks, …) - Variadic functions — call
printf/snprintf/… withcall_va/5 - Parse transform — declare C bindings as module attributes, get typed wrappers generated at compile time
Quick start
Prerequisites
libffi-devBuild
rebar3 compile
This compiles both priv/cffi_nif.so (via the pc plugin) and
priv/cffi_port (via make).
Basic call
{ok, Lib} = cffi:load("libm.so.6"),
{ok, 2.0} = cffi:call(Lib, "sqrt", double, [{double, 4.0}]),
{ok, 8.0} = cffi:call(Lib, "pow", double, [{double, 2.0}, {double, 3.0}]).API reference
Type specs
| Erlang atom | C type |
|---|---|
void | void |
bool | _Bool |
int8 | int8_t |
uint8 | uint8_t |
int16 | int16_t |
uint16 | uint16_t |
int32 | int32_t |
uint32 | uint32_t |
int64 | int64_t |
uint64 | uint64_t |
float | float |
double | double |
pointer | void * |
string | const char * (null-terminated; encoded as binary on the Erlang side) |
Named registered types (structs, unions, enums, typedefs) can be used anywhere a type spec is expected.
cffi — NIF mode
%% Load a shared library.
{ok, Lib} = cffi:load("libm.so.6").
%% Call a C function.
{ok, Val} = cffi:call(Lib, "func_name", RetType, [{ArgType, ArgVal}, ...]).
%% Call a variadic C function (NFixed = number of fixed arguments).
{ok, N} = cffi:call_va(Lib, "snprintf", int32, 3,
[{pointer, Buf}, {uint64, BufSize}, {string, "%d"}, {int32, 42}]).
%% Allocate C memory (zeroed).
Ptr = cffi:alloc(Bytes).
Ptr = cffi:alloc_type(TypeAtom). %% sizeof(Type) bytes
Ptr = cffi:alloc_type(TypeAtom, Count). %% Count × sizeof(Type) bytes
Ptr = cffi:alloc_struct(StructName).
%% Free C memory.
ok = cffi:free(Ptr).
%% Read / write a typed value at a pointer.
Val = cffi:read(Ptr, TypeSpec).
ok = cffi:write(Ptr, TypeSpec, Value).
%% Raw byte I/O.
Bin = cffi:read_bytes(Ptr, Size).
ok = cffi:write_bytes(Ptr, Binary).
%% Pointer arithmetic.
Ptr2 = cffi:ptr_add(Ptr, ByteOffset).
Null = cffi:null().
true = cffi:is_null(Null).
%% Scoped allocation (pointer freed on exit, even on exception).
Result = cffi:with_alloc(Bytes, fun(Ptr) -> ... end).
Result = cffi:with_alloc(TypeAtom, Count, fun(Ptr) -> ... end).
Type system (cffi_type / cffi)
%% Define a C struct (fields laid out per System V AMD64 ABI).
cffi:defcstruct(point, [{x, double}, {y, double}]).
%% Define a C union.
cffi:defcunion(int_or_float, [{i, int32}, {f, float}]).
%% Define an enum (auto-numbered from 0, or explicit values).
cffi:defcenum(color, [red, green, blue]).
cffi:defcenum(errno_t, [{ok, 0}, {eperm, 1}, {enoent, 2}]).
%% typedef alias.
cffi:defctype(size_t, uint64).
cffi:defctype(my_double, double).
%% Introspect.
16 = cffi:type_size(point). %% 2 × 8
8 = cffi:align_of(point).
%% Struct field access.
FPtr = cffi:field_ptr(Ptr, point, x).
1.5 = cffi:struct_read(Ptr, point, x).
ok = cffi:struct_write(Ptr, point, y, 2.5).
#{x := 1.5, y := 2.5} = cffi:struct_to_map(Ptr, point).
ok = cffi:map_to_struct(Ptr, point, #{x => 0.0, y => 1.0}).
%% Array element access.
Ptr2 = cffi:array_ptr(Ptr, int32, 3). %% pointer to element 3
42 = cffi:array_read(Ptr, int32, 0).
ok = cffi:array_write(Ptr, int32, 0, 42).
C→Erlang callbacks (cffi_callback)
%% Create a C-callable function pointer backed by an Erlang fun.
{ok, Cb} = cffi_callback:new(RetType, [ArgType, ...], fun(A, B, ...) -> ... end).
%% Extract the C function pointer (pass to cffi:call as {pointer, FnPtr}).
FnPtr = cffi_callback:func_ptr(Cb).
%% Free when no longer needed (do not free while C code may still call it).
ok = cffi_callback:free(Cb).Example — qsort with an Erlang comparator:
{ok, Libc} = cffi:load("libc.so.6"),
Arr = cffi:alloc_type(int32, 5),
%% ... fill array ...
{ok, Cb} = cffi_callback:new(int32, [pointer, pointer],
fun(PA, PB) ->
A = cffi:read(PA, int32), B = cffi:read(PB, int32),
if A < B -> -1; A > B -> 1; true -> 0 end
end),
{ok, ok} = cffi:call(Libc, "qsort", void,
[{pointer, Arr}, {uint64, 5}, {uint64, 4},
{pointer, cffi_callback:func_ptr(Cb)}]),
cffi_callback:free(Cb),
cffi:free(Arr).Notes:
- Each closure instance is not re-entrant (do not invoke it from two C threads simultaneously).
- The callback server process is linked to the spawning process; it dies when the spawning process dies.
-
Errors in the Erlang fun are caught; the C call continues with return value
0.
Port safe mode (cffi_port)
The port mode API mirrors cffi exactly. A crash in the C library terminates the port subprocess but leaves the BEAM VM alive.
%% Load — starts a dedicated OS subprocess.
{ok, Lib} = cffi_port:load("libm.so.6").
%% Same call / alloc / read / write / free / ptr_add / struct / array API.
{ok, 2.0} = cffi_port:call(Lib, "sqrt", double, [{double, 4.0}]).
{ok, N} = cffi_port:call_va(Lib, "snprintf", int32, 3, [...]).
Ptr = cffi_port:alloc(Lib, 8). %% NB: takes Lib, not just bytes
3.14 = cffi_port:read(Ptr, double).
ok = cffi_port:write(Ptr, double, 3.14).
P2 = cffi_port:ptr_add(Ptr, 4). %% returns {port_ptr, Pid, Addr}
%% Explicit close (or the subprocess exits with the gen_server).
ok = cffi_port:close(Lib).Differences from NIF mode:
| NIF mode | Port mode | |
|---|---|---|
| Crash safety | C crash kills VM | C crash kills subprocess only |
| Speed | Fast (in-process) | Slower (inter-process RPC) |
| Pointers |
NIF resource (reference()) | {port_ptr, Pid, Addr} tuple |
alloc | cffi:alloc(Bytes) | cffi_port:alloc(Lib, Bytes) |
| Callbacks | cffi_callback:new/3 | Not supported |
Parse transform (cffi_transform)
Declare C bindings at the module level; the transform generates typed wrapper functions at compile time.
-module(my_math).
-compile({parse_transform, cffi_transform}).
-cffi_lib("libm.so.6").
-cffi_fun({sqrt, double, [double]}).
-cffi_fun({pow, double, [double, double]}).
%% Different Erlang name / C symbol:
-cffi_fun({{cbrt_val, "cbrt"}, double, [double]}).This generates:
sqrt(X) -> case cffi:call('$cffi_lib$'(), "sqrt", double, [{double,X}]) of ...
pow(X, Y) -> case cffi:call('$cffi_lib$'(), "pow", double, [...]) of ...
cbrt_val(X) -> case cffi:call('$cffi_lib$'(), "cbrt", double, [{double,X}]) of ...
The library is loaded lazily on first call and cached via persistent_term.
Variadic functions
Use call_va/5 whenever the C function takes ...:
{ok, Lib} = cffi:load("libc.so.6"),
Buf = cffi:alloc(64),
%% snprintf(buf, 64, "%d + %d = %d", 1, 2, 3)
{ok, 7} = cffi:call_va(Lib, "snprintf", int32, 3,
[{pointer, Buf}, {uint64, 64}, {string, "%d + %d = %d"},
{int32, 1}, {int32, 2}, {int32, 3}]),
cffi:read_bytes(Buf, 7). %% <<"1 + 2 = 3">> (minus null)NFixed (third integer argument) is the number of fixed parameters before ....
For snprintf that is 3 (buf, size, fmt).
C default argument promotions are applied automatically to variadic arguments:
float arguments are promoted to double as required by the C standard.
Port mode uses the identical cffi_port:call_va/5 signature.
Testing
rebar3 ct| Suite | Cases | Coverage |
|---|---|---|
cffi_nif_SUITE | 15 | load, call, void return, all primitive types, ptr arithmetic, bytes I/O, typedef, enum, struct, union, nested struct, arrays, scoped alloc, callbacks, varargs |
cffi_port_SUITE | 6 | load/close, call, alloc/rw, struct via ptr_add, array, crash isolation |
cffi_va_SUITE | 7 | NIF varargs (int/float/string/multi), port varargs (int/float/string) |
Architecture
cffi.erl ← public API, type resolution, NIF mode
cffi_port.erl ← public API, port safe mode (gen_server)
cffi_type.erl ← type registry (ETS), C layout engine
cffi_callback.erl ← C→Erlang callback server
cffi_transform.erl ← parse transform
cffi_nif.erl ← NIF stub (loads priv/cffi_nif.so)
c_src/cffi_nif.c ← NIF: libffi dispatch, resource GC, callbacks
c_src/cffi_port.c ← Port executable: same ops over stdin/stdout
The ETS type registry (cffi_type_registry) is created lazily on first use.
It is a public named table; ownership follows the process that first calls
defcstruct/defcenum/defctype. In long-running applications, define
your types in a supervisor init or application start callback so the
table owner is a persistent process.
Limitations
- Callbacks in port mode — not yet implemented (requires a duplex protocol).
- Variadic callbacks —
cffi_callbackdoes not support variadic C signatures. - Windows — not tested; the Makefile and rebar port_env only cover Linux/macOS.
- Closures are not re-entrant — each
cffi_callbackclosure instance serialises calls through a single Erlang process.