PHP FFI 拓展简单介绍
PHP 在今年(2019)11月末发布了 7.4.0 稳定版,新增的 FFI(Foreign Function Interface) 拓展让 PHP 也具有了直接调用第三方库函数的能力。
编写测试共享库
先用 C 语言编写一个简单的共享库:
// test.h
extern void print_array(int*, int);
// test.c
#include <stdio.h>
void print_array(int* a, int size) {
for (int i = 0; i < size; i++) {
printf("%d ", a[i]);
}
printf("\n");
}
测试代码的功能比较简单,只导出了一个打印整型数组的函数。将它编译为共享库:
$ gcc -c -Wall -Werror -fpic test.c
$ gcc -shared -o libtest.so test.o
$ ls
libtest.so test.c test.h test.o
调用函数
使用最简单的方式调用函数:
// ffi.php
$ffi = FFI::cdef('void print_array(int*, int);', __DIR__ . '/libtest.so');
$nums = FFI::new('int[2]');
$nums[0] = 1;
$nums[1] = 2;
$ffi->print_array($nums, count($nums));
// output
// 1 2
FFI::cdef
接受 code
和 lib
两个参数,code
是外部函数的相关定义,就是 C 语言中的头文件(不支持预处理相关功能),lib
就是刚才编译的共享库的保存位置。这种调用方式最大的好处库文件的保存位置很灵活。
还可以使用 FFI::load
调用函数:
// load.h
#define FFI_LIB "/path/to/libtest.so"
void print_array(int*, int);
$ffi = FFI::load('load.h');
$nums = FFI::new('int[2]');
$nums[0] = 1;
$nums[1] = 2;
$ffi->print_array($nums, count($nums));
在非预加载的情况下两种方法并没有什么太大的差别,FFI::load
会解析 load.h
头文件中 FFI_LIB
定义作为 lib
的地址。
需要注意的是,虽然示例中的 $nums
可以和数组一样赋值,也可以用 count
获取整数的个数,但它并不是 PHP 中的普通数组:
$nums = FFI::new('int[2]');
$nums[] = 1;
// PHP Fatal error: Uncaught FFI\Exception: Cannot add next element to object of type FFI\CData ...
从报错信息可以看出它其实是一个 FFI\CData
对象:
// ext/ffi/ffi.c
typedef struct _zend_ffi_cdata {
zend_object std; // 操作的其实是这个
zend_ffi_type *type;
void *ptr;
void *ptr_holder;
zend_ffi_flags flags;
} zend_ffi_cdata;
并且 FFI
拓展还自定义了 FFI\CDdata
对象的 handlers
:
// ext/ffi/ffi.c
memcpy(&zend_ffi_cdata_handlers, zend_get_std_object_handlers(), sizeof(zend_object_handlers));
zend_ffi_cdata_handlers.get_constructor = zend_fake_get_constructor;
zend_ffi_cdata_handlers.free_obj = zend_ffi_cdata_free_obj;
zend_ffi_cdata_handlers.clone_obj = zend_ffi_cdata_clone_obj;
zend_ffi_cdata_handlers.read_property = zend_ffi_cdata_read_field;
zend_ffi_cdata_handlers.write_property = zend_ffi_cdata_write_field;
zend_ffi_cdata_handlers.read_dimension = zend_ffi_cdata_read_dim;
zend_ffi_cdata_handlers.write_dimension = zend_ffi_cdata_write_dim;
zend_ffi_cdata_handlers.get_property_ptr_ptr = zend_fake_get_property_ptr_ptr;
zend_ffi_cdata_handlers.has_property = zend_fake_has_property;
zend_ffi_cdata_handlers.unset_property = zend_fake_unset_property;
zend_ffi_cdata_handlers.has_dimension = zend_fake_has_dimension;
zend_ffi_cdata_handlers.unset_dimension = zend_fake_unset_dimension;
zend_ffi_cdata_handlers.get_method = zend_fake_get_method;
zend_ffi_cdata_handlers.get_class_name = zend_ffi_cdata_get_class_name;
zend_ffi_cdata_handlers.do_operation = zend_ffi_cdata_do_operation;
zend_ffi_cdata_handlers.compare_objects = zend_ffi_cdata_compare_objects;
zend_ffi_cdata_handlers.cast_object = zend_ffi_cdata_cast_object;
zend_ffi_cdata_handlers.count_elements = zend_ffi_cdata_count_elements;
zend_ffi_cdata_handlers.get_debug_info = zend_ffi_cdata_get_debug_info;
zend_ffi_cdata_handlers.get_closure = zend_ffi_cdata_get_closure;
zend_ffi_cdata_handlers.get_properties = zend_fake_get_properties;
zend_ffi_cdata_handlers.get_gc = zend_fake_get_gc;
// ...
static void zend_ffi_cdata_write_dim(zval *object, zval *offset, zval *value) /* {{{ */
{
zend_ffi_cdata *cdata = (zend_ffi_cdata*)Z_OBJ_P(object);
zend_ffi_type *type = ZEND_FFI_TYPE(cdata->type);
zend_long dim;
void *ptr;
zend_ffi_flags is_const;
// 这里必须设置数组的下标,否则直接报错
if (offset == NULL) {
zend_throw_error(zend_ffi_exception_ce, "Cannot add next element to object of type FFI\\CData");
return;
}
// ...
}
预加载
通过前面的方法可以满足调用外部函数的需求,但是如果每次请求都重复同样的加载操作,在性能上会有很大的损耗。拿 FFI::cdef
来说:
// ext/ffi/ffi.c
ZEND_METHOD(FFI, cdef) /* {{{ */
{
// ...
ZEND_FFI_VALIDATE_API_RESTRICTION();
ZEND_PARSE_PARAMETERS_START(0, 2)
Z_PARAM_OPTIONAL
Z_PARAM_STR(code)
Z_PARAM_STR(lib)
ZEND_PARSE_PARAMETERS_END();
if (lib) {
// 通过 dlopen 加载对应的库文件
handle = DL_LOAD(ZSTR_VAL(lib));
// ...
}
// ...
if (code) {
// 从 code 字符串中解析出所有的 symbol 和 tag
if (zend_ffi_parse_decl(ZSTR_VAL(code), ZSTR_LEN(code)) != SUCCESS) {
// ...
}
if (FFI_G(symbols)) {
zend_string *name;
zend_ffi_symbol *sym;
// 从共享库中获取每个 symbol 的内存地址
ZEND_HASH_FOREACH_STR_KEY_PTR(FFI_G(symbols), name, sym) {
if (sym->kind == ZEND_FFI_SYM_VAR) {
addr = DL_FETCH_SYMBOL(handle, ZSTR_VAL(name));
// ...
sym->addr = addr;
} else if (sym->kind == ZEND_FFI_SYM_FUNC) {
// ...
}
} ZEND_HASH_FOREACH_END();
}
}
ffi = (zend_ffi*)zend_ffi_new(zend_ffi_ce);
ffi->lib = handle;
ffi->symbols = FFI_G(symbols);
ffi->tags = FFI_G(tags);
// ...
// 返回对象
RETURN_OBJ(&ffi->std);
}
好在 FFI 还提供了预加载的功能,可以在脚本执行前把需要用到的共享库加载解析后,将数据保存在内存中,方便后续重复使用。
预加载功能需要配合 opcache 使用,先修改 php.ini
相关设置:
[opcache]
; 需要预加载的入口文件,可以定义相关的预加载逻辑
opcache.preload=/path/to/preload.php
[ffi]
; FFI API restriction. Possibe values:
; "preload" - enabled in CLI scripts and preloaded files (default)
; "false" - always disabled
; "true" - always enabled
ffi.enable=preload
ffi.enable
设置一共有三个选项,false
关闭,true
表示在所有的 SAPI 中开启,preload
表示只在 preload 脚本中和 cli 模式下开启。这里推荐使用默认的 preload
选项,在 PHP-FPM 等模式下禁用 FFI,保证系统安全。
编写预加载脚本:
// preload.php
FFI::load(__DIR__ . '/preload.h');
opcache_compile_file(__DIR__ . '/test.php');
// preload.h
#define FFI_SCOPE "TEST"
#define FFI_LIB "/path/to/libtest.so"
void print_array(int*, int);
// test.php
final class Test {
private static $ffi = null;
public function __construct() {
if (is_null(self::$ffi)) {
self::$ffi = FFI::scope('TEST');
}
}
public function printArray(array $arr) {
$count = count($arr);
$nums = FFI::new(sprintf('int[%d]', $count));
// 注意!演示代码,未进行任何有效性检查
foreach ($arr as $k => $v) {
$nums[$k] = $v;
}
self::$ffi->print_array($nums, $count);
}
}
在 preload.php
预加载文件主要进行两个操作,一是加载解析需要的共享库文件;二是将需要用到的方法进行封装,并且进行预编译。
这次在 preload.h
头文件中增加了 FFI_SCOPE
定义,让不同的共享库保存到不同的地方(不同的 Hash 键值),可以防止外部函数冲突等问题,默认的 SCOPE 为 C
。
最重要的是需要将对应的外部函数封装起来,提供对应的 PHP 访问接口,比如示例中提供了 Test 类,可以在其他 SAPI 的模式下实例化后调用。
原地开启一个内置 server 来测试一下效果:
// ffi.php
$ffi = FFI::scope('TEST');
$ php -S 127.0.0.1:8000 -t .
$ curl http://127.0.0.1:8000/ffi.php
<b>Fatal error</b>: Uncaught FFI\Exception: FFI API is restricted by ffi.enable configuration directive in /path/to/ffi.php:3
PHP 内置 server 的 SAPI 值为 cli-server
,因此直接在脚本中使用 FFI::scope
会报错。
// ext/ffi/php_ffi.h
typedef enum _zend_ffi_api_restriction {
ZEND_FFI_DISABLED = 0,
ZEND_FFI_ENABLED = 1,
ZEND_FFI_PRELOAD = 2, // 当前在 php.ini 设置的值
} zend_ffi_api_restriction;
// ext/ffi/ffi.c
static zend_always_inline int zend_ffi_validate_api_restriction(zend_execute_data *execute_data) /* {{{ */
{
// ZEND_FFI_PRELOAD 要大于 ZEND_FFI_ENABLED
if (EXPECTED(FFI_G(restriction) > ZEND_FFI_ENABLED)) {
ZEND_ASSERT(FFI_G(restriction) == ZEND_FFI_PRELOAD);
// 内置 server 的 SAPI 为 cli_server,FFI_G(is_cli) 为 0
// 当前 ffi.php 的执行环境也不是预加载脚本
if (FFI_G(is_cli)
|| (execute_data->prev_execute_data
&& (execute_data->prev_execute_data->func->common.fn_flags & ZEND_ACC_PRELOADED))
|| (CG(compiler_options) & ZEND_COMPILE_PRELOAD)) {
return 1;
}
} else if (EXPECTED(FFI_G(restriction) == ZEND_FFI_ENABLED)) {
return 1;
}
// 会走到这里
return zend_ffi_disabled();
}
static zend_never_inline int zend_ffi_disabled(void) /* {{{ */
{
zend_throw_error(zend_ffi_exception_ce, "FFI API is restricted by \"ffi.enable\" configuration directive");
return 0;
}
将代码修改一下,用我们在预加载脚本中封装的类代替:
// ffi.php
$o = new Test();
$o->printArray([1, 2, 3]);
$ curl http://127.0.0.1:8000/ffi.php
$ php -S 127.0.0.1:8000 -t .
1 2 3 // <- 这里打印的是数组的元素
[Sun Dec 15 20:13:45 2019] 127.0.0.1:34854 [200]: GET /ffi.php
[Sun Dec 15 20:13:45 2019] 127.0.0.1:34854 Closing