ffi

Call C libraries. Declare the functions you need with their C signatures, copied from the header, and call them like Oxigen functions. For how this works inside the VM, see FFI architecture.

Oxigen
introduce ffi

libm := ffi.open("m", {
    "cos": "double(double)",
    "pow": "double(double x, double y)",
})
println(libm.cos(0.0))        // 1
println(libm.pow(2.0, 10.0))  // 1024
FunctionSignatureReturns
openopen(lib, decls?)a module of the declared functions
fnfn(lib, signature)one function
cstructcstruct(name, fields)the struct's name
callbackcallback(signature, func)a C function pointer
allocalloc(size)pointer to zeroed memory
newnew(type, value?)pointer to one zeroed type
freefree(ptr)None
sizeofsizeof(type)int
readread(ptr, type, index?)the value
writewrite(ptr, type, value, index?)None
stringstring(ptr, len?)str
cstringcstring(s)pointer to a C copy of s
symsym(lib, name)address of a symbol

#Libraries

A library is one of:

  • a bare name: "m", "sqlite3", "z". Oxigen tries the platform's file names (libm.dylib; libm.so, libm.so.6, ...; m.dll) and the usual install directories (on macOS, Homebrew's /opt/homebrew/lib).
  • a file name or path: "libfoo.so.2", "./build/libgame.dylib".
  • None: the C runtime (malloc, strlen, qsort, snprintf, ...).

A library is loaded once and stays loaded.

#open(lib, decls)

Binds every function in decls, a map of name to C signature, and returns them as a module:

Oxigen
libc := ffi.open(None, {
    "strlen": "size_t(const char *s)",
    "getenv": "char *(const char *name)",
})
println(libc.strlen("hello"))     // 5
println(libc.getenv("HOME"))      // /Users/you

The key is the C function's name. To call it something else, put the C name in the signature: {"len": "size_t strlen(const char *)"}. A missing symbol or a bad signature fails here, not on the first call.

#fn(lib, signature)

One function, named in the signature:

Oxigen
abs := ffi.fn(None, "int abs(int)")
println(abs(-3))   // 3

Given a function pointer instead of a library, fn makes it callable; the name can then be left out: ffi.fn(ptr, "int(int)").

#Signatures and types

Signatures are C: ret name(params), with the name optional and parameter names allowed and ignored. const, struct and volatile are ignored too.

C typeOxigen value
char, short, int, long, long long, their unsigned forms, int8_t ... uint32_t, ssize_t, intptr_t, enum Xint
unsigned long long, uint64_t, size_t, uintptr_tuint
boolbool
float, doublefloat
char *str in and out (None for NULL)
void *, function pointersint address, None for NULL
T *an address, or an array or map to pass in and out (see below)
a struct from cstruct, by valuemap of field name to value
voidreturn type only: None

The short names i8 ... i64, u8 ... u64, f32, f64, str and ptr work too.

A function returning char * gives a copy of the string. If you must free the pointer afterwards, declare the return as void *, read it with ffi.string, then free it.

#Pointer parameters

For a T * parameter, pass an Oxigen array (or a map, for a struct). It is copied into C memory for the call and copied back afterwards, which is how out parameters work:

Oxigen
libm := ffi.open("m", {"frexp": "double(double x, int *exp)"})
exp := [0]
println(libm.frexp(8.0, exp))   // 0.5
println(exp[0])                 // 4

An address from ffi.alloc or another C call works too, and so does None for NULL. A void * parameter takes a string or an address, never an array: declare the element type (int *) to pass one.

#Variadic functions

End the parameters with .... Extra arguments go as C's default promotions: int as long long, float as double, str as char *.

Oxigen
libc := ffi.open(None, {"snprintf": "int(char *buf, size_t n, const char *fmt, ...)"})
buf := ffi.alloc(64)
libc.snprintf(buf, 64, "%s=%d (%.1f)", "x", 42, 2.5)
println(ffi.string(buf))   // x=42 (2.5)
ffi.free(buf)

#cstruct(name, fields)

Declares a C struct so its name works as a type in signatures, by value or as Name *. List the fields as in C:

Oxigen
ffi.cstruct("div_t", "int quot; int rem")
ffi.cstruct("Vec2", ["double x", "double y"])   // an array works too

libc := ffi.open(None, {"div": "div_t(int, int)"})
println(libc.div(7, 2))   // {quot: 3, rem: 1}

Struct values are maps. Missing fields are zero, and an array or tuple sets the fields in order. Layout and padding follow the platform's C ABI. A struct can contain another struct declared before it.

To paste a declaration straight from a header, give it as one string, with its braces escaped (\{ \}), since a bare { in an Oxigen string starts an interpolation:

Oxigen
ffi.cstruct("typedef struct \{ int id; char *name; \} Item;")

#callback(signature, func)

A C function pointer that calls func, for APIs that take callbacks:

Oxigen
libc := ffi.open(None, {"qsort": "void(int *base, size_t n, size_t size, void *cmp)"})
cmp := ffi.callback("int(const void *a, const void *b)", fun(a, b) {
    ffi.read(a, "int") - ffi.read(b, "int")
})
xs := [5, 1, 4, 2, 3]
libc.qsort(xs, len(xs), ffi.sizeof("int"), cmp)
println(xs)   // [1, 2, 3, 4, 5]

The callback gets its arguments converted the same way as return values: a char * arrives as a str, a struct as a map. If it fails, the C call that ran it fails with the same error once C returns. A callback stays valid until the program exits. C must call it on the script's thread while a C function the script called is still running, which is how sorting, iteration and event-polling APIs work. A callback that a C thread calls later, on its own, fails.

#Memory

alloc, new and cstring return C memory that you free with free. read and write take a C type and an optional index into an array of them:

Oxigen
p := ffi.new("Vec2", {"x": 1.5, "y": -2.0})
println(ffi.read(p, "Vec2"))        // {x: 1.5, y: -2}
println(ffi.read(p, "double", 1))   // -2   (the second double)
ffi.write(p, "double", 9.0)
ffi.free(p)

ffi.string(ptr) reads a NUL-terminated string, and ffi.string(ptr, len) reads len bytes. ffi.cstring(s) makes a C copy of s that outlives the call, for a char * struct field or a global.

#sym(lib, name)

The address of a symbol, usually a global variable a library exports: ffi.read(ffi.sym("mylib", "mylib_version"), "int").

#A complete example

SQLite, with an out parameter and a callback per row:

Oxigen
introduce ffi

sq := ffi.open("sqlite3", {
    "open": "int sqlite3_open(const char *path, void **db)",
    "exec": "int sqlite3_exec(void *db, const char *sql, void *cb, void *arg, char **err)",
    "close": "int sqlite3_close(void *db)",
})

db := [None]
sq.open(":memory:", db)
sq.exec(db[0], "create table t(name text); insert into t values ('a'), ('b');", None, None, None)

row := ffi.callback("int(void *arg, int n, char **vals, char **cols)", fun(_, n, vals, cols) {
    println(ffi.read(vals, "char *", 0))
    0
})
sq.exec(db[0], "select name from t", row, None, None)
sq.close(db[0])

#Threads

A library opened at the top level of a script cannot be used inside a diverge task: the task runs on its own VM, and C functions do not cross into it. Put the declarations in a function and call it inside the task. Each thread loads the library once and reuses it after that:

Oxigen
fun libc() { ffi.open(None, {"labs": "long(long)"}) }

println(diverge each n in [-1, -2, -3] { libc().labs(n) })   // [1, 2, 3]

Structs are declared per thread too: call ffi.cstruct inside the task before using one there.

#Safety

The signature is a promise the program cannot check. A wrong one, a bad pointer or a freed buffer is undefined behaviour, as in C, and can crash the process.

Every ffi function needs the ffi capability. A host that calls vm.restrict must list Capability::Ffi to allow it, and should grant it only to a script it would trust as native code.

#Limits

  • Struct fields cannot be inline arrays (char name[16]). Declare the bytes as separate fields, or use a pointer and ffi.read with an index.
  • No unions, bit-fields or long double.
  • Not available in the browser playground (WebAssembly).
  • The prebuilt Linux release binaries are statically linked against musl, which cannot load shared libraries: every ffi.open fails there. Build from source (./scripts/install.sh) to use ffi on Linux. The macOS releases are not affected.
  • Windows support (LoadLibrary, the universal CRT for None) is untested.