
Experimenting with PHP FFI I - Initial Pitfalls
Why not use it? Could be fun, what are the pitfalls?
What is FFI
FFI is a mechanism which allows developers to use C functions from shared libraries. Sounds simple, doesn’t it?
Let’s start exploring what is possible!
Creating a Simple Library
Let’s create a dumb library first. Let’s call it meddle
.
libmeddle
provides two implementations for this function:
/**
* Increments the given int by
* - 1 if called using C
* - 10 if called using CPP
* then returns it.
*/
int meddle(int);
A weird function to be sure, but I wanted to test possible incompatibilities
between C and CPP and ease of use with stl
. I shall extend this in the future.
Nothing interesting in the library, the code can be viewed on the sites GitHub.
Only thing we need to know for now is that I have installed it in ./c-lib/pfx/
.
Enabling FFI in PHP
As per Documentation
I need to configure
with --with-ffi
.
If you’re using a prebuild binary, the vendor might have compiled it without
support for FFI. This can be found out using
php --info | grep "Configure Command" | grep -c ffi.
For me, the output is sadly
0
so my version wasn’t build with FFI. Let’s fix that by compiling PHP on our own.
- Get source
tar xvf
untar source./buildconf --force
build config./configure --with-ffi
and some other options, if required (I would recommend setting a different--prefix
)make -j`nproc`
makemake install
install./bin/php --info | grep "Configure Command" | grep -c ffi
now outputs1
I already had everything needed, you might need some libs, consult the php README
if needed.
If you’re running php using a provider/hosting and FFI is disabled, then you can ask them to enable it, like for Plesk.
Finally, just whack ffi.enable=true
at the end of php.ini
.
Testing Out
Let’s test with a sample from Rasmus Lerdorf
<?php
$ffi = FFI::cdef(
"int printf(const char *format, ...);",
"libc.so.6");
$ffi->printf("Hello %s!\n", "world");
Hello world!
It seems to work!
3. Using our own libraries with FFI
Since we already have some headers, lets just simply load them! What could go wrong?
<?php
$ffi_header = __DIR__ . '/c-lib/include/meddle.h';
$ffi = FFI::load($ffi_header);
echo "Hello world! " . $ffi->meddle(1);
Even before running, we might spy a small issue - how does FFI know which library to use? Let’s run it anyway:
Fatal error: Uncaught FFI\ParserException: ';' expected, got '<STRING>' at line 5 in /home/somrlik/Sites/ffi-experiments/run.php:9
Stack trace:
#0 /home/somrlik/Sites/ffi-experiments/run.php(9): FFI::load('/home/somrlik/S...')
#1 {main}
Next FFI\Exception: Failed loading '/home/somrlik/Sites/ffi-experiments/c-lib/include/meddle.h' in /home/somrlik/Sites/ffi-experiments/run.php:9
Stack trace:
#0 /home/somrlik/Sites/ffi-experiments/run.php(9): FFI::load('/home/somrlik/S...')
#1 {main}
thrown in /home/somrlik/Sites/ffi-experiments/run.php on line 9
OK, so we don’t event parse the header. From the documentation:
C preprocessor directives are not supported, i.e. #include, #define and CPP macros do not work, except for special cases listed below.
Wow, no #include
, #ifdef
, #endif
? I guess we can live with it for now, so we just remove
the extern "C"
compatibility magic. Let’s go again I guess:
Fatal error: Uncaught FFI\Exception: Failed resolving C function 'meddle' in /home/somrlik/Sites/ffi-experiments/run.php:9
Stack trace:
#0 /home/somrlik/Sites/ffi-experiments/run.php(9): FFI::load('/home/somrlik/S...')
#1 {main}
thrown in /home/somrlik/Sites/ffi-experiments/run.php on line 9
Of course, we cannot resolve the function. But… how do we exactly do that? FFI:load
only takes in
the path to a header file. As per the documentation:
The header file may contain a #define statement for the
FFI_LIB
variable to specify the library it exposes.
If it is a system library only the file name is required, e.g.:#define FFI_LIB "libc.so.6"
.
If it is a custom library, a relative path is required, e.g.:#define FFI_LIB "./mylib.so"
.
Seems simple enough to add, although this exposes an issue - what if the headers and the library are somewhere completely different? Furthermore, what if the system we are on supports multiple versions of libraries being used, and we don’t (or do) care about which one is used?
Side-note: some users on newer Macs might have had issues with some older software
requiring openssl@1.1
which at some point got replaced with openssl@3.0
with no warning
(no one reads Mac changelogs, prove me wrong).
For now, it seems that we need to provide the actual path to the library itself in the header. Since I’m not installing this lib on my system (since it sucks), let’s just add the path to the header:
#ifndef LIB_MEDDLE
#define LIB_MEDDLE
#define FFI_LIB ../lib/libmeddle-c.so
int meddle(int);
#endif
And let’s try again:
Fatal error: Uncaught FFI\Exception: Failed resolving C function 'meddle' in /home/somrlik/Sites/ffi-experiments/run.php:9
Stack trace:
#0 /home/somrlik/Sites/ffi-experiments/run.php(9): FFI::load('/home/somrlik/S...')
#1 {main}
thrown in /home/somrlik/Sites/ffi-experiments/run.php on line 9
No dice. Wait a second, what did the documentation say?
C preprocessor directives are not supported, i.e. #include, #define and CPP macros do not work
Oh, like they don’t work at all. I need to remove them. Let’s just create a new file, meddle_ffi.h
:
#define FFI_LIB "../lib/libmeddle-c.so"
int meddle(int);
Still no dice, but a better error:
Fatal error: Uncaught FFI\Exception: Failed loading '/home/somrlik/Sites/ffi-experiments/c-lib/pfx/include/meddle_ffi.h', bad FFI_LIB define in /home/somrlik/Sites/ffi-experiments/run.php:11
Stack trace:
#0 /home/somrlik/Sites/ffi-experiments/run.php(11): FFI::load('/home/somrlik/S...')
#1 {main}
thrown in /home/somrlik/Sites/ffi-experiments/run.php on line 11
I will now skip forward tens of minutes of debugging to tell you the answer:
The path in
FFI_LIB
is relative to current working directory of the script.
Wait what?
So if I hypothetically wrote a composer package which included a C library, I would have to make the headers different for every machine? Even worse, what if someone decided to run the script from a different working directory? Or god knows, someone might even move the library! Or the headers! Or they might even install them on their system!
Or maybe I would like to run my script under a web server, which inherently changes cwd.
Relative paths just don’t work for
FFI_LIB
. Don’t try.
$ ./run.php
CWD: /home/somrlik/Sites/ffi-experiments
Header: /home/somrlik/Sites/ffi-experiments/c-lib/pfx/include/meddle_ffi.h
Hello world! 2
$ cd vendor
$ ../run.php
CWD: /home/somrlik/Sites/ffi-experiments/vendor
Header: /home/somrlik/Sites/ffi-experiments/c-lib/pfx/include/meddle_ffi.h
Fatal error: Uncaught FFI\Exception: Failed loading './c-lib/pfx/lib/libmeddle-c.so' (./c-lib/pfx/lib/libmeddle-c.so: cannot open shared object file: No such file or directory) in /home/somrlik/Sites/ffi-experiments/run.php:12
Stack trace:
#0 /home/somrlik/Sites/ffi-experiments/run.php(12): FFI::load('/home/somrlik/S...')
#1 {main}
thrown in /home/somrlik/Sites/ffi-experiments/run.php on line 12
So, what to do? LD_PRELOAD
our library? Well, it works, when FFI_LIB
is set to "libmeddle-c.so"
.
Let’s not meddle with this dark magic (for now).
”Fixing” FFI Loading
My best guess is that someone just implemented the loading behavior using dlopen()
and said good enough.
Let’s write a simple wrapper around FFI loading. After some testing, this is what I came up with.
When you load
a library:
- It creates a temporary header file
- Adds absolute
FFI_LIB
path - Copies the original header
FFI:load
s the temp header
I am not using preloading or FFI::scope
since there might be some issues waiting with these options,
but let’s not get ahead of myself.
<?php
declare(strict_types=1);
namespace Somrlik\FfiExperiments;
class FFILoader
{
/** @var \FFI|null[] */
private array $instances = [];
public function load(string $name, string $header, string $library): bool {
if (array_key_exists($name, $this->instances)) {
return false;
}
$source = fopen($header, 'r+x');
if ($source === false) return false;
$temp = tmpfile();
if ($temp === false) {
fclose($source);
return false;
}
$written = fwrite($temp, sprintf("#define FFI_LIB \"%s\"\n", $library));
if (!$written) {
fclose($temp);
fclose($source);
return false;
}
if (false === stream_copy_to_stream($source, $temp)) {
fclose($temp);
fclose($source);
return false;
}
fclose($source);
$oldCwd = getcwd();
try {
if (fflush($temp) === false) {
return false;
}
$newCwd = dirname($library);
if (chdir($newCwd) === false) {
return false;
}
$ffi = \FFI::load(stream_get_meta_data($temp)['uri']);
if ($ffi === null) return false;
$this->instances[$name] = $ffi;
return true;
} finally {
chdir($oldCwd);
fclose($temp);
}
}
public function get(string $name): \FFI|null {
return $this->instances[$name] ?? null;
}
}
And
$ffiLoader = new FFILoader();
$ffiLoader->load('meddle-c', __DIR__ . '/c-lib/pfx/include/meddle_ffi.h', __DIR__ . '/c-lib/pfx/lib/libmeddle-c.so');
$ffiLoader->load('meddle-cpp', __DIR__ . '/c-lib/pfx/include/meddle_ffi.h', __DIR__ . '/c-lib/pfx/lib/libmeddle-cpp.so');
echo sprintf("Meddle from C: %d", $ffiLoader->get('meddle-c')->meddle(1)) . PHP_EOL;
echo sprintf("Meddle from CPP: %d", $ffiLoader->get('meddle-cpp')->meddle(1)) . PHP_EOL;
now works.
Meddle from C: 2
Meddle from CPP: 11
Conclusion
The first rodeo made me a bit skeptical.
Needing to write a separate header is fine and even understandable - there would be so many issues with having to basically reimplement the whole preprocessor. Let’s not even think about the different declarations that might be dependent on platform (foreshadowing).
The system of determining which library should be chosen as the implementation of
the headers is not optimal fucking unusable. Still, it at least works for system-wide
libraries, just don’t go around playing with the feature too much I guess.
Take care.
Edit this page on Github.com