Menu

FFI — calling native C functions

Manticore calls into native C libraries directly. You declare a PHP function, tag it with attributes naming the library and the C symbol, and the compiler emits a direct call to that symbol — no wrapper runtime, no marshalling layer. This is how the compiler itself reaches libc (malloc, fopen, write, …) and how extensions bind libraries like zlib or libcurl.

There is no php.ini, no extension loader and no dlopen. A binding is compile-time and link-time only.


The binding#

use Ffi\Library;
use Ffi\Symbol;

#[Library('c'), Symbol('getpid')]
function getpid(): int {}

Because the body is ignored when compiled, write an empty or trivial one. That body is the Zend fallback: the same source also runs under stock PHP (the difftest oracle, Zend-hosted dev tools), where the attribute is inert and the body executes instead. Keep it harmless ({}, return 0;, return '';).

⚠ Bindings are free functions, and anything else is a compile error. #[Library] on a class or #[Symbol] on a method is rejected at the declaration. A method binding cannot work: the lowered function carries a receiver parameter with no C counterpart, so only a static could ever bind — and a static method binding is a namespaced free function with worse ergonomics. It also could never cross a .o boundary, because a module's .sig exports free functions only, which is the property that makes Runtime\Libc\* importable at all. Group related bindings with a namespace; see src/Runtime/Libc.php and src/Runtime/Openssl.php.


Type mapping#

With no #[CType], the PHP hint decides the C ABI type — the fallback, not the whole story (see the next section):

PHP typeC / LLVM typeNotes
inti64the native word
float / doubledouble
booli1
voidvoidreturn only
stringptr (char*)the pointer to the string bytes
\Ffi\Ptrptr (void*)an opaque handle (see below)
a class type / untypedptr

The wrapper converts at the boundary: an int argument rides as i64, a string / \Ffi\Ptr is inttoptr'd to a real pointer, a float is bitcast to double; the C return is converted back (pointer → int carrier, double → bitcast, bool → zero-extend). You write ordinary PHP types — the ABI glue is generated.

#[Library('z'), Symbol('crc32')]
function __ffi_crc32(int $crc, string $buf, int $len): int { return 0; }
//                  ^ i64        ^ char*       ^ i64    -> i64

#[CType] — saying what the C prototype actually says#

PHP's int covers C's char, short, int, long and size_t alike, and the wrapper has to know the real width to convert correctly. #[Ffi\CType] says which one. It goes at function level for the return, and on individual parameters:

#[Library('c'), Symbol('write')]
function write(#[CType('int')] int $fd, string $buf,
               #[CType('size_t')] int $n): int {}
tokenLLVMnotes
voidvoidreturn only
booli1
char / uchari8
short / ushorti16
int / uinti32
long ulong longlong ulonglong size_t ssize_t off_ti64
floatfloatC's 32-bit float, not PHP's float
doubledouble
ptrptran address carried as PHP int

The multi-word C spellings (unsigned int, long long, …) are accepted as aliases. Anything else — including a platform typedef like nfds_t or socklen_t — is a compile error: only you can resolve such a typedef's width, and it is not always the same one (glibc types nfds_t as unsigned long, Darwin as unsigned int). The set is closed on purpose; when unknown tokens were silently ignored, 'unsigned int' and 'nfds_t' sat in the tree unnoticed.

long, size_t, ssize_t and off_t are 64-bit here. Manticore compiles for the host and every target it builds for is LP64.

Why the return token is not optional. A C function returning int returns a 32-bit value. On arm64 the callee does mov w0, #-1, which zeroes the upper half of x0, so a wrapper declaring an i64 return reads 4294967295 instead of -1:

#[Library('c'), Symbol('signalfd'), CType('int'), \Ffi\Weak]
function signalfd(#[CType('int')] int $fd, \Ffi\Ptr $mask,
                  #[CType('int')] int $flags): int { return -1; }

Signedness picks the direction — uint zero-extends where int sign-extends. Hand-written syscall stubs happen to sign-extend because they write the full x0, which is why on Darwin only the real C libraries (OpenSSL, PCRE2) were exposed, while Linux — where glibc is C all the way down — is affected systemically.

The token and the PHP hint must agree, and the compiler enforces it. A \Ffi\Ptr or string carries an address, so an integer token on one is rejected outright: sign-extending a returned pointer is exactly how SSL_read's WANT_READ (-1) became a 4 GB length that memmove'd off the end of the heap. A float hint takes only float / double, and void is a return type only. Use ptr for the handle-as-int idiom (SSL_CTX_new, strstr).

⚠ Every binding of one C symbol must declare the same signature. Declares are keyed by symbol, so a second, differently-typed binding of close used to be dropped silently and whichever wrapper was emitted first decided what all call sites were typed against. The emitter now rejects the module and names both bindings.

#[Ffi\Weak] — a symbol that may not exist on this target#

Emits declare extern_weak instead of a plain declare, so a symbol missing at link time resolves to null rather than failing the link. Used for platform-specific symbols referenced from a cross-platform build (epoll_* and signalfd from a macOS build).

The decorated function must never be called where the symbol is absent — guard the call with a runtime OS branch. extern_weak makes the reference tolerable, not the call.

On Darwin, ld64 still errors on a weak-undefined symbol unless it is allowed explicitly. The driver derives -Wl,-U,_<sym> from every extern_weak the module actually emitted, and carries the set across a library's .sig for a binding that lives in a linked .o — so the allowance cannot drift from the bindings, and a program that pulls in no weak binding gets no -U flags at all. GNU ld auto-binds a weak-undefined to 0, so Linux needs no flag.

Never mark a binding weak only to be lenient: under --as-needed (the link line's own, and the built-in default of Alpine's and Ubuntu's gcc) a weak reference does not make a shared library needed. A library reached through weak bindings alone is dropped from the link and every call through it jumps to address 0.

#[Ffi\Variadic($fixed)] — a C variadic callee#

$fixed is the number of named parameters, the ones before the C ...:

#[Library('c'), Symbol('fcntl'), Variadic(2), CType('int')]
function fcntl(#[CType('int')] int $fd, #[CType('int')] int $cmd,
               #[CType('int')] int $arg): int {}

Without it the wrapper emits a fixed-arity call, and on Darwin arm64 — whose variadic ABI passes varargs on the stack — the callee reads register garbage where it does va_arg. Parameter #[CType] matters most here: va_arg(ap, T) reads T's natural size and advances by it, so an int handed over in an 8-byte slot leaves every later read misaligned.

$fixed must be between 0 and the binding's arity; anything else is a compile error.


Opaque handles — \Ffi\Ptr#

For C handles you hold but never dereference from PHP (a FILE*, a directory stream, a library cookie), use \Ffi\Ptr — a readonly wrapper over a raw address:

#[Library('c'), Symbol('fopen')]
function fopen(string $path, string $mode): \Ffi\Ptr {}

#[Library('c'), Symbol('fclose')]
function fclose(\Ffi\Ptr $stream): int { return 0; }

Its whole surface is Ptr::null(), isNull() and offset(int). There is deliberately no read* family — reading through a raw address from PHP was removed. \Ffi\Ptr is an address and nothing more; it has no automatic free, and the caller owns the lifetime of whatever it points at.


C calling back into PHP — fn_to_ptr#

fn_to_ptr('name') yields the address of a compiled PHP function as a \Ffi\Ptr, so a C library can call into PHP: qsort's comparator, libxml2's structured error handler, sqlite3's user functions, curl's write callback.

#[Library('c'), Symbol('qsort')]
function qsort_(\Ffi\Ptr $base, #[CType('size_t')] int $n,
                #[CType('size_t')] int $sz, \Ffi\Ptr $cmp): void {}

// int (*)(const void *, const void *)
function cmp_asc(\Ffi\Ptr $a, \Ffi\Ptr $b): int
{
    $x = peek_i64($a, 0);
    $y = peek_i64($b, 0);
    return $x <=> $y;
}

qsort_($buf, $n, 8, fn_to_ptr('cmp_asc'));

This works because every PHP function is emitted as define i64 @manticore_<name>(i64, i64, …), and on both arm64 and x86_64 that is the C ABI for integer and pointer arguments — same registers, same order. A C callee declaring int truncates our i64 return exactly as it would any long-returning function.

The name must be a string LITERAL. The address is a relocation resolved at compile time, so a typo is a compile error rather than a link error — and that reference is also what keeps the function alive, since the only dead-stripping here is the linker's.

What is refused (rather than miscompiled)#

RefusedWhy
float parameter or returnFloats travel in FP registers under AAPCS and SysV; this ABI puts every argument in a GP register, so the callee would read an unrelated one.
string / array parameterA C caller passes a bare char * or struct pointer. Those PHP types mean a headered string / our array layout. Take \Ffi\Ptr and convert with cstr_to_str / str_from_buffer.
by-reference parameterThere is no caller variable to bind.
returns by referenceSame.

A C varargs callback (void (*)(void *, const char *, ...), e.g. libxml2's xmlGenericErrorFunc) cannot be expressed as a PHP function at all. Where a library offers both, bind the non-varargs one — libxml2's structured error handler is the usable half.

⚠ An exception must not escape a callback. A throw inside one longjmps to the nearest enclosing PHP try, which sits above the C frame — so the C library's own frames are skipped and whatever state it was mid-way through updating is left behind. Catch inside the callback and signal failure through its return value, the way the C API expects.

The callback runs on the C library's stack with the caller's arena live. Keep it short, and do not let it allocate anything the C side is expected to own.


Linking the native library#

#[Library('name')] is what puts the library on the link line. Nothing else is required:

#[Library('z'), Symbol('crc32')]
function __ffi_crc32(int $crc, string $buf, int $len): int { return 0; }
// -> the link gets -lz

The manifest's extensions[].link remains the escape hatch — for a library no #[Library] names, or one whose flags are not a bare -l:

{
  "extensions": { "zlib": { "src": "ext/zlib", "link": ["z"] } },
  "applications": [
    { "src": "src/app", "output": "bin/app", "extensions": ["zlib"] }
  ]
}

Both sources are deduped by -l<name>, so declaring a library twice is harmless. An application may only name an extension that the top-level extensions map declares — naming an unknown one fails the build. See modules.md for the full flow.

The native library never touches Manticore's arena / refcount heap, so it adds no memory-safety surface of its own — it is an ordinary C archive linked by cc.


Memory across the boundary#

A buffer that comes back from a C allocator (malloc, calloc, or bytes read into one) is a raw pointer with no Manticore string header — the refcount runtime must never touch it. Before such a buffer flows into normal PHP code, copy it into a real string:

$buf = calloc($size + 1, 1);     // raw libc block — NO rc header
$n   = fread($buf, 1, $size, $fp);
return substr($buf, 0, $n);      // owned, rc-headered string — safe to return

substr and friends allocate a properly headered string; the raw calloc block is left alone (the compiler does not refcount FFI-call results). Returning the raw buffer directly would let rc_release run on a header-less block and corrupt the heap.


Worked examples#

libc, as the compiler's own runtime declares it (src/Runtime/Libc.php):

// ssize_t write(int fd, const void *buf, size_t n)
#[Library('c'), Symbol('write')]
function write(#[CType('int')] int $fd, string $buf,
               #[CType('size_t')] int $n): int { return 0; }

// char *getenv(const char *name) — a pointer, so no integer token
#[Library('c'), Symbol('getenv')]
function getenv(string $name): string { return ''; }

zlib, as an extension (ext/zlib/crc32.php):

#[Library('z'), Symbol('crc32')]
function __ffi_crc32(int $crc, string $buf, int $len): int { return 0; }

function ext_zlib_crc32(string $s): int {
    return __ffi_crc32(0, $s, strlen($s));   // crc32("hello") === 907060870
}

Ownership — checked, never lowered#

Borrow, BorrowMut, Take, Give and StaticPtr record who owns a pointer across the boundary:

#[Library('c'), Symbol('malloc'), Give]
function malloc(#[CType('size_t')] int $n): \Ffi\Ptr {}   // caller must free

#[Library('c'), Symbol('free')]
function free(#[Take] \Ffi\Ptr $p): void {}               // callee takes it

#[Library('c'), Symbol('strlen')]
function strlen(#[Borrow] string $s): int {}              // callee must not free

⚠ Nothing is freed on your behalf. These are checked, not lowered — they emit no code at all. What the compiler enforces:

rule
Every Ffi\ attribute needs a #[Ffi\Symbol] on the same declarationthere is no C callee to describe otherwise
Give and StaticPtr are mutually exclusivethe callee cannot both hand ownership over and keep it forever
Borrow / BorrowMut / Take — at most one per parameterone parameter, one ownership story
ownership only on a pointer-carrying parameter / returna number has no lifetime
Take never on a string; Give never on a string returnsee below

The last row is the one that is safety rather than tidiness. A PHP string is refcount-owned — its rc word sits before the bytes — so handing one to C's free() corrupts the allocator's metadata. A C buffer is the mirror image: it has no rc header, so letting rc_release reach it corrupts the heap. Declare either as \Ffi\Ptr and copy across the boundary (see Memory).

Repeats are not rejected: Zend does not enforce them for userland attributes at compile time, and diverging from it here buys nothing.

There is no runtime Ffi\dlopen / dlsym / call(...). That layer was removed — it bottomed out on primitives that stub-linked to 0 — and the static #[Library, Symbol] path is the supported, zero-overhead one.

Edit this page on GitHub