Press "Enter" to skip to content

PHP FFI in detail — a brand-new way to write PHP extensions

Coming with PHP 7.4 is an extension I think is very useful: PHP FFI (Foreign Function interface). Quoting a description from the PHP FFI RFC:

For PHP, FFI opens a way to write PHP extensions and bindings to C libraries in pure PHP.

Yes, FFI provides direct mutual calling between high-level languages; and for PHP, FFI lets us conveniently call various libraries written in C.

A large number of PHP extensions are in fact wrappers around existing C libraries — the commonly used mysqli, curl, gettext, and so on — and there are many similar extensions in PECL.

The traditional way: when we need to use the capabilities of some existing C library, we have to write a wrapper in C, wrapping them up as an extension. In this process everyone needs to learn how to write PHP extensions. Of course there are some convenient ways now, like Zephir. But there's still some learning cost. With FFI, though, we can directly call functions in C libraries from within a PHP script.

And over C's decades of history, a huge number of excellent libraries have accumulated. FFI directly lets us conveniently enjoy this vast resource.

Getting back on point, today I'll introduce with an example how we can use PHP to call libcurl to fetch the content of a web page. Why libcurl? Doesn't PHP already have the curl extension? Well, first, because I'm quite familiar with libcurl's API; second, precisely because it exists, it's convenient for comparison — isn't the ease-of-use difference between the traditional extension approach and the FFI approach?

First, let's take this article you're currently reading as the example. I now need to write some code to fetch its content. If using the traditional PHP curl extension, we'd roughly write it like this:

<?php

$url = "https://www.laruence.com/en/2020/03/11/5475.html";
$ch = curl_init();

curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0);

curl_exec($ch);

curl_close($ch);

(Because my site is https, there's one extra SSL_VERIFYPEER setting.) So what if we use FFI?

First, we need to enable PHP 7.4's ext/ffi. Note that PHP-FFI requires libffi-3 or higher.

Then, we need to tell PHP FFI what the prototype of the function we're calling looks like. For this we can use FFI::cdef, whose prototype is:

FFI::cdef([string $cdef = "" [, string $lib = null]]): FFI

In string $cdef, we can write C-language function-style declarations. FFI will parse it and learn what the signature of the function we want to call in the string $lib library looks like. In this example, we use three libcurl functions; we can find all their declarations in libcurl's documentation, e.g. for curl_easy_init.

Concretely for this example, we write a curl.php that contains everything to be declared. The code is as follows:

$libcurl = FFI::cdef(<<<CTYPE
void *curl_easy_init();
int curl_easy_setopt(void *curl, int option, ...);
int curl_easy_perform(void *curl);
void curl_easy_cleanup(void *handle);
CTYPE
 , "libcurl.so"
 );

There's one spot here: the docs say the return value is CURL *, but in fact since our example doesn't dereference it, only passes it around, let's avoid the hassle and use void * instead.

However, there's another annoying thing: PHP has predefined the values of options like CURLOPT_, but now we need to define them ourselves. The simple way is to look at curl's header files, find the corresponding values, and add them in:

<?php
const CURLOPT_URL = 10002;
const CURLOPT_SSL_VERIFYPEER = 64;

$libcurl = FFI::cdef(<<<CTYPE
void *curl_easy_init();
int curl_easy_setopt(void *curl, int option, ...);
int curl_easy_perform(void *curl);
void curl_easy_cleanup(void *handle);
CTYPE
 , "libcurl.so"
 );

OK, the definition part is done. Now we complete the actual logic part. The whole code becomes:

<?php
require "curl.php";

$url = "https://www.laruence.com/en/2020/03/11/5475.html";

$ch = $libcurl->curl_easy_init();
$libcurl->curl_easy_setopt($ch, CURLOPT_URL, $url);
$libcurl->curl_easy_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0);

$libcurl->curl_easy_perform($ch);

$libcurl->curl_easy_cleanup($ch);

How's that? Compared with using the curl extension, isn't it just as concise?

Next, let's make it a bit more complex — that is, what if we don't want the result output directly, but returned as a string? For PHP's curl extension, we just need to call curl_setopt to set CURLOPT_RETURNTRANSFER to 1. But in libcurl there's actually no capability to directly return a string; instead it provides a WRITEFUNCTION callback function. When data is returned, libcurl calls this function. In fact, PHP's curl extension does it this way too.

Currently we can't directly pass a PHP function as a callback to libcurl via FFI, so we have two ways to do it:

1. Use WRITEDATA. By default libcurl calls fwrite as the callback, and we can pass libcurl an fd via WRITEDATA so it doesn't write to stdout but to this fd instead.
2. We write a simple C function ourselves, bring it in via FFI, and pass it to libcurl.

Let's use the first way first. First we need to use fopen. This time we declare the prototype by defining a C header file (file.h):

void *fopen(char *filename, char *mode);
void fclose(void * fp);

Just like file.h, we put all the libcurl function declarations into curl.h too:

#define FFI_LIB "libcurl.so"

void *curl_easy_init();
int curl_easy_setopt(void *curl, int option, ...);
int curl_easy_perform(void *curl);
void curl_easy_cleanup(CURL *handle);

Then we can use FFI::load to load the .h file:

static function load(string $filename): FFI;

But how do we tell FFI which corresponding library to load? As above, we defined a FFI_LIB macro to tell FFI that these functions come from libcurl.so. When we load this h file with FFI::load, PHP FFI will automatically load libcurl.so.

Then why doesn't fopen need a library specified? That's because FFI also looks up symbols in the global symbol table, and fopen is a standard library function — it's long since present.

OK, now the whole code becomes:

<?php
const CURLOPT_URL = 10002;
const CURLOPT_SSL_VERIFYPEER = 64;
const CURLOPT_WRITEDATA = 10001;

$libc = FFI::load("file.h");
$libcurl = FFI::load("curl.h");

$url = "https://www.laruence.com/en/2020/03/11/5475.html";
$tmpfile = "/tmp/tmpfile.out";

$ch = $libcurl->curl_easy_init();
$fp = $libc->fopen($tmpfile, "a");

$libcurl->curl_easy_setopt($ch, CURLOPT_URL, $url);
$libcurl->curl_easy_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0);
$libcurl->curl_easy_setopt($ch, CURLOPT_WRITEDATA, $fp);
$libcurl->curl_easy_perform($ch);

$libcurl->curl_easy_cleanup($ch);

$libc->fclose($fp);

$ret = file_get_contents($tmpfile);
@unlink($tmpfile);

But this way requires a temporary relay file, which still isn't elegant enough. Now let's use the second way. To use the second way, we need to write a callback function ourselves in C and pass it to libcurl:

#include <stdlib.h>
#include <string.h>
#include "write.h"

size_t own_writefunc(void *ptr, size_t size, size_t nmember, void *data) {
        own_write_data *d = (own_write_data*)data;
        size_t total = size * nmember;

        if (d->buf == NULL) {
                d->buf = malloc(total);
                if (d->buf == NULL) {
                        return 0;
                }
                d->size = total;
                memcpy(d->buf, ptr, total);
        } else {
                d->buf = realloc(d->buf, d->size + total);
                if (d->buf == NULL) {
                        return 0;
                }
                memcpy(d->buf + d->size, ptr, total);
                d->size += total;
        }

        return total;
}

void * init() {
        return &own_writefunc;
}
Note the init function here. Because in PHP FFI, at the current version (2020-03-11) we have no way to directly obtain a function pointer, so we defined this function to return the address of own_writefunc.

Finally we define the write.h header file used above:

#define FFI_LIB "write.so"

typedef struct _writedata {
        void *buf;
        size_t size;
} own_write_data;

void *init();

Notice that we also defined FFI_LIB in the header file, so this header file can be used jointly by both write.c and our upcoming PHP FFI.

Then we compile the write function into a dynamic library:

gcc -O2 -fPIC -shared  -g  write.c -o write.so

OK, now the whole code becomes:

<?php
const CURLOPT_URL = 10002;
const CURLOPT_SSL_VERIFYPEER = 64;
const CURLOPT_WRITEDATA = 10001;
const CURLOPT_WRITEFUNCTION = 20011;

$libcurl = FFI::load("curl.h");
$write  = FFI::load("write.h");

$url = "https://www.laruence.com/en/2020/03/11/5475.html";

$data = $write->new("own_write_data");

$ch = $libcurl->curl_easy_init();

$libcurl->curl_easy_setopt($ch, CURLOPT_URL, $url);
$libcurl->curl_easy_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0);
$libcurl->curl_easy_setopt($ch, CURLOPT_WRITEDATA, FFI::addr($data));
$libcurl->curl_easy_setopt($ch, CURLOPT_WRITEFUNCTION, $write->init());
$libcurl->curl_easy_perform($ch);

$libcurl->curl_easy_cleanup($ch);

ret = FFI::string($data->buf, $data->size);

Here, we use FFI::new ($write->new) to allocate a block of memory for struct _write_data:

function FFI::new(mixed $type [, bool $own = true [, bool $persistent = false]]): FFICData

$own indicates whether this memory management uses PHP's memory management. By default, the memory we allocate goes through PHP's lifecycle management and doesn't need to be freed actively. But sometimes you might want to manage it yourself, so you can set $own to false, and at the appropriate time you need to call FFI::free to actively free it.

Then we pass $data to libcurl as WRITEDATA. Here we use FFI::addr to get $data's actual memory address:

static function addr(FFICData $cdata): FFICData;

Then we pass own_write_func to libcurl as WRITEFUNCTION. This way, whenever there's a return, libcurl will call our own_write_func to handle the return, and at the same time pass write_data as a custom parameter to our callback function.

Finally we use FFI::string to convert a block of memory into a PHP string:

static function FFI::string(FFICData $src [, int $size]): string

When $size is not provided, FFI::string will stop when it encounters a Null byte.

OK, let's run it?

However, after all, loading a .so directly in PHP on every request would be a big performance problem, so we can also adopt the preload approach. In this mode, we use opcache.preload to load it at PHP startup:

ffi.enable=1
opcache.preload=ffi_preload.inc

ffi_preload.inc:

<?php
FFI::load("curl.h");
FFI::load("write.h");

But how do we reference the loaded FFI? For this we need to modify these two .h header files to add FFI_SCOPE. For example, curl.h:

#define FFI_LIB "libcurl.so"
#define FFI_SCOPE "libcurl"

void *curl_easy_init();
int curl_easy_setopt(void *curl, int option, ...);
int curl_easy_perform(void *curl);
void curl_easy_cleanup(void *handle);

Correspondingly we add FFI_SCOPE of "write" to write.h as well, and then our script should now look like this:

<?php
const CURLOPT_URL = 10002;
const CURLOPT_SSL_VERIFYPEER = 64;
const CURLOPT_WRITEDATA = 10001;
const CURLOPT_WRITEFUNCTION = 20011;

$libcurl = FFI::scope("libcurl");
$write  = FFI::scope("write");

$url = "https://www.laruence.com/en/2020/03/11/5475.html";

$data = $write->new("own_write_data");

$ch = $libcurl->curl_easy_init();

$libcurl->curl_easy_setopt($ch, CURLOPT_URL, $url);
$libcurl->curl_easy_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0);
$libcurl->curl_easy_setopt($ch, CURLOPT_WRITEDATA, FFI::addr($data));
$libcurl->curl_easy_setopt($ch, CURLOPT_WRITEFUNCTION, $write->init());
$libcurl->curl_easy_perform($ch);

$libcurl->curl_easy_cleanup($ch);

ret = FFI::string($data->buf, $data->size);

That is, we now use FFI::scope instead of FFI::load to reference the corresponding functions.

static function scope(string $name): FFI;

Then there's another problem. FFI gives us a lot of flexibility, but after all, directly calling C library functions is still very risky. We should only allow users to call functions we've verified. So ffi.enable=preload should come into play. When we set ffi.enable=preload, only functions in the opcache.preload script can call FFI, and functions written by users have no way to call it directly.

Let's slightly modify ffi_preload.inc into ffi_safe_preload.inc:

<?php
class CURLOPT {
	const URL = 10002;
	const SSL_VERIFYHOST = 81;
	const SSL_VERIFYPEER = 64;
	const WRITEDATA = 10001;
	const WRITEFUNCTION = 20011;
}

FFI::load("curl.h");
FFI::load("write.h");

function get_libcurl() : FFI {
	return FFI::scope("libcurl");
}

function get_write_data($write) : FFICData {
	return $write->new("own_write_data");
}

function get_write() : FFI {
	return FFI::scope("write");
}

function get_data_addr($data) : FFICData {
	return FFI::addr($data);
}

function paser_libcurl_ret($data) :string{
	return FFI::string($data->buf, $data->size);
}

That is, we define all the functions that call FFI APIs in the preload script, and then our example becomes (ffi_safe.php):

<?php
$libcurl = get_libcurl();
$write  =  get_write();
$data = get_write_data($write);

$url = "https://www.laruence.com/en/2020/03/11/5475.html";


$ch = $libcurl->curl_easy_init();

$libcurl->curl_easy_setopt($ch, CURLOPT::URL, $url);
$libcurl->curl_easy_setopt($ch, CURLOPT::SSL_VERIFYPEER, 0);
$libcurl->curl_easy_setopt($ch, CURLOPT::WRITEDATA, get_data_addr($data));
$libcurl->curl_easy_setopt($ch, CURLOPT::WRITEFUNCTION, $write->init());
$libcurl->curl_easy_perform($ch);

$libcurl->curl_easy_cleanup($ch);

$ret = paser_libcurl_ret($data);

This way, via ffi.enable=preload, we can restrict that all FFI APIs can only be called by our controllable preload script, and users can't call them directly. This lets us do as much as possible of the security-guarantee work within these functions, thereby ensuring a degree of safety.

OK, after this example you should have a fairly deep understanding of FFI. For the detailed PHP API description, you can refer to: PHP-FFI Manual. If you're interested, go find a C library and give it a try?

You can download the examples from this article on my github: FFI example

Finally, one more word: the example is just to demonstrate functionality, so it omits a lot of error-branch checks and captures. When you write your own, remember to add them. After all, using FFI gives you 1000 ways to make PHP segfault crash, so be careful. 🙂

Be First to Comment

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.