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.
use Ffi\Library;
use Ffi\Symbol;
#[Library('c'), Symbol('getpid')]
function getpid(): int {}#[Symbol('getpid')]names the C symbol. A function tagged with it is an extern forwarder: the compiler emits a thin wrapper that calls the C@getpiddirectly and ignores the PHP body.#[Library('c')]names the library the symbol lives in, and that is what puts it on the link line (see Linking).'c'(libc / libSystem) needs no flag — it is always linked.
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 during the cold bootstrap, 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.
With no #[CType], the PHP hint decides the C ABI type — the fallback, not the whole
story (see the next section):
| PHP type | C / LLVM type | Notes |
|---|---|---|
int |
i64 |
the native word |
float / double |
double |
|
bool |
i1 |
|
void |
void |
return only |
string |
ptr (char*) |
the pointer to the string bytes |
\Ffi\Ptr |
ptr (void*) |
an opaque handle (see below) |
| a class type / untyped | ptr |
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 -> i64PHP'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 {}| token | LLVM | notes |
|---|---|---|
void |
void |
return only |
bool |
i1 |
|
char / uchar |
i8 |
|
short / ushort |
i16 |
|
int / uint |
i32 |
|
long ulong longlong ulonglong size_t ssize_t off_t |
i64 |
|
float |
float |
C's 32-bit float, not PHP's float |
double |
double |
|
ptr |
ptr |
an 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.
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.
$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.
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.
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.
| Refused | Why |
|---|---|
float parameter or return |
Floats 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 parameter |
A 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 parameter | There is no caller variable to bind. |
| returns by reference | Same. |
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
throwinside one longjmps to the nearest enclosing PHPtry, 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.
#[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- libc / libSystem (
#[Library('c')]) is implicit — always linked, never flagged. - Any other name resolves through
pkg-config --libs <name>, then<name>-config --libs, then a bare-l<name>. The probe order is what makes OpenSSL and PCRE2 work on a host where Homebrew keeps them off the default search path. - Requirements are collected per emitted wrapper, then carried in the module's
.sig. That matters because linking is whole-program while a wrapper is emitted once, in the module that owns the source: a program callingpreg_matchgets the pcre2 wrapper out oflib/manticore_stdlib.oand has no#[Library]of its own to derive-lpcre2-8from. -dead_strip_dylibs/--as-neededdrop a library the program never actually reaches.
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.
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 returnsubstr 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.
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
}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 declaration |
there is no C callee to describe otherwise |
Give and StaticPtr are mutually exclusive |
the callee cannot both hand ownership over and keep it forever |
Borrow / BorrowMut / Take — at most one per parameter |
one parameter, one ownership story |
| ownership only on a pointer-carrying parameter / return | a number has no lifetime |
Take never on a string; Give never on a string return |
see 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.