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 接受 codelib 两个参数,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